SDK Development Guidelines
Rules for developing the OSAPI Go SDK (pkg/sdk/). These apply to the client
library, orchestrator engine, and any new SDK packages.
Package Structure
pkg/sdk/
client/ # HTTP client wrapping generated OpenAPI code
gen/ # Generated code (DO NOT edit manually)
osapi.go # Client constructor, service wiring
response.go # Response[T], Collection[T], error helpers
errors.go # Typed error hierarchy
node.go # NodeService methods
node_types.go # SDK result types + gen→SDK conversions
... # One file per domain service
orchestrator/ # DAG-based task runner
plan.go # Plan, TaskFunc, TaskFuncWithResults
task.go # Task with deps, guards, error strategies
runner.go # DAG levelization and execution
result.go # Result, HostResult, TaskResult, Report
bridge.go # CollectionResult, StructToMap helpers
options.go # Hooks, error strategies, plan options
platform/ # Platform detection utilities
Never Expose Generated Types
The gen/ package contains auto-generated OpenAPI client code. No generated
type should appear in any public SDK method signature. The SDK exists
specifically to hide gen/ behind clean, stable types.
For every gen.* request or response type used internally, define an SDK-level
equivalent:
// BAD — leaks gen type into public API
func (s *DockerService) Create(
ctx context.Context,
hostname string,
body gen.DockerCreateRequest, // consumer must import gen
) (*Response[Collection[DockerResult]], error)
// GOOD — SDK-defined type wraps gen internally
func (s *DockerService) Create(
ctx context.Context,
hostname string,
opts DockerCreateOpts, // SDK type, no gen import needed
) (*Response[Collection[DockerResult]], error)
Inside the method, build the gen.* request from the SDK type. Map zero values
to nil pointers where the gen type uses *string, *bool, etc.
Result Types
JSON Tags Required
Every exported struct field on every result/model type must have a
json:"..." tag with a snake_case key:
// GOOD
type HostnameResult struct {
Hostname string `json:"hostname"`
Error string `json:"error,omitempty"`
Changed bool `json:"changed"`
Labels map[string]string `json:"labels,omitempty"`
}
Tags are required because:
StructToMap(the bridge helper) uses JSON round-tripping to convert structs tomap[string]any. Without tags, Go uses PascalCase field names which don't match the API's snake_case keys.- Consumers may serialize SDK types to JSON for logging, storage, or forwarding. Consistent keys matter.
omitempty Rules
- Use
omitemptyon: pointer fields, optional slices/maps, error strings, optional string fields - Do not use
omitemptyon:Changed bool(must always be present), required fields likeHostname
Collection Pattern
Multi-target operations return Collection[T]:
type Collection[T any] struct {
Results []T `json:"results"`
JobID string `json:"job_id"`
}
Use Collection.First() for safe access to single-result responses instead of
indexing Results[0] directly.
Changed Field
Every mutation result type must include Changed bool. The provider sets it,
the agent extracts it via extractChanged(), the API passes it through, and the
SDK exposes it. The full chain must be consistent.
Response Pattern
All service methods return *Response[T]:
type Response[T any] struct {
Data T
rawJSON []byte
}
Data— the typed SDK resultRawJSON()— the raw HTTP response body for CLI--jsonmode and orchestratorResult.Datapopulation
Error Handling
checkError
All service methods use checkError() to convert HTTP status codes into typed
errors:
if err := checkError(
resp.StatusCode(),
resp.JSON400,
resp.JSON401,
resp.JSON403,
resp.JSON500,
); err != nil {
return nil, err
}
Error Wrapping
Wrap errors with context at the SDK boundary:
// GOOD
return nil, fmt.Errorf("docker create: %w", err)
return nil, fmt.Errorf("invalid audit ID: %w", err)
// BAD — no context
return nil, err
Nil Response Guard
After checkError, always guard against nil response bodies:
if resp.JSON200 == nil {
return nil, &UnexpectedStatusError{APIError{
StatusCode: resp.StatusCode(),
Message: "nil response body",
}}
}
Orchestrator Bridge Helpers
The orchestrator package provides two bridge helpers for converting SDK client
responses into orchestrator Result values. These exist so consumers like
osapi-orchestrator don't need to reimplement them.
CollectionResult
Converts a Collection[T] response into an orchestrator Result with per-host
details:
return orchestrator.CollectionResult(resp.Data, resp.RawJSON(),
func(r client.HostnameResult) orchestrator.HostResult {
return orchestrator.HostResult{
Hostname: r.Hostname,
Changed: r.Changed,
Error: r.Error,
}
},
), nil
- First arg: the
Collection[T]fromresp.Data - Second arg:
resp.RawJSON()to populateResult.Data(ornilto skip) - Third arg: mapper function converting each result to
HostResult
StructToMap
Converts any struct with JSON tags to map[string]any. Use for non-collection
responses:
return &orchestrator.Result{
JobID: resp.Data.JobID,
Changed: resp.Data.Changed,
Data: orchestrator.StructToMap(resp.Data),
}, nil
Adding a New Service
When adding a new domain service to the SDK client:
- Create
{domain}.go— service struct + methods, each calling gen client and converting to SDK types - Create
{domain}_types.go— SDK result types with JSON tags, SDK request types (wrapping gen types), and gen→SDK conversion functions - Create
{domain}_public_test.go— tests usinghttptest.Servermocks, 100% coverage - Wire in
osapi.go— add service field toClient, initialize inNew() - Never import
genin examples or consumer code — if a consumer needs to importgen, the SDK wrapper is incomplete
Testing
- Use
httptest.Serverto mock API responses - Test all HTTP status code paths (200, 400, 401, 403, 404, 500)
- Test nil response body path
- Test transport errors (unreachable server)
- Test all optional field branches in request type mapping
- Target 100% coverage on all SDK packages (excluding
gen/)
Consumer Guidance
SDK consumers (like osapi-orchestrator) should:
- Use SDK
client.*types directly — do not redefine them locally - Use
CollectionResultandStructToMapfrom the orchestrator bridge - Use
Collection.First()instead ofResults[0] - Never import
gen— if you need to, the SDK is missing a wrapper - Never panic on SDK responses — always propagate errors