Skip to main content

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 to map[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 omitempty on: pointer fields, optional slices/maps, error strings, optional string fields
  • Do not use omitempty on: Changed bool (must always be present), required fields like Hostname

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 result
  • RawJSON() — the raw HTTP response body for CLI --json mode and orchestrator Result.Data population

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] from resp.Data
  • Second arg: resp.RawJSON() to populate Result.Data (or nil to 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:

  1. Create {domain}.go — service struct + methods, each calling gen client and converting to SDK types
  2. Create {domain}_types.go — SDK result types with JSON tags, SDK request types (wrapping gen types), and gen→SDK conversion functions
  3. Create {domain}_public_test.go — tests using httptest.Server mocks, 100% coverage
  4. Wire in osapi.go — add service field to Client, initialize in New()
  5. Never import gen in examples or consumer code — if a consumer needs to import gen, the SDK wrapper is incomplete

Testing

  • Use httptest.Server to 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 CollectionResult and StructToMap from the orchestrator bridge
  • Use Collection.First() instead of Results[0]
  • Never import gen — if you need to, the SDK is missing a wrapper
  • Never panic on SDK responses — always propagate errors