Skip to content

OpenAPI-first contract and code generation ​

plexsphere's /v1 HTTP surface is specified spec-first: the authoritative contract lives in api/openapi/plexsphere-v1.yaml (OpenAPI 3.1), and every Go artefact that talks to /v1 — server stubs, Go client, shared Go types — is generated from that single file. A drift gate refuses any PR whose committed artefacts diverge from a fresh make generate run, so the spec is not merely "documentation": it is the compilation unit.

This document describes how to evolve the spec safely, which code paths change together, and the commands you need during local iteration. For the higher-level rationale — why the API is versioned the way it is, what counts as additive vs. breaking, and how deprecation is signalled to clients — read the companion README §API Versioning & Compatibility.

Authoring workflow ​

A change to the /v1 surface follows exactly one path. The order matters: if any earlier step is skipped the drift gate (make generate-check) or the spec linter (make openapi-lint) will fail the PR long before review.

  1. Edit the spec first. Open api/openapi/plexsphere-v1.yaml and add, rename, or remove operations, request/response schemas, parameters, or error shapes. The spec declares openapi: 3.1.0 and info.version: v1; neither value is editable inside a major version (raising either one is part of a /vN+1 rollout). Operation IDs are PascalCase and stable — Spectral rejects camelCase, and a rename is always a breaking change (the Go client and TS operations map key off the id).
  2. Lint the spec. Run make openapi-lint to invoke the pinned Spectral ruleset (tools/openapi/.spectral.yaml). The ruleset layers six plexsphere-specific rules on top of spectral:oas: PascalCase operation IDs, mandatory response schemas, application/problem+json as the only acceptable media type for 4xx/5xx responses (RFC 9457), every problem+json body $ref-ing the shared Problem or PermissionDenied envelope , and the paired write-once gates plexsphere-write-once-non-post-forbidden / plexsphere-write-once-post-must-be-issue-response that pin the x-plexsphere-once: true vendor extension to the response body of a POST on the write-once allow-list — the BootstrapToken plaintext is the canonical user of the flag; the session JWT, the API-token plaintexts, and the presigned execution-output URL are the others. Failures here are authored, not generated — fix them in the YAML.

Two pinned overrides relax rules where a divergence is deliberate: the AttachSession WebSocket gateway (101-only, no 2xx success body) and GET /v1/health (503 carries the same HealthStatus body as its 200 so probes branch on the status code while dashboards still see the failing checks, rather than an application/problem+json envelope the unhealthy process may no longer serve). 3. Regenerate the artefacts. Run make generate. The target installs the pinned oapi-codegen via GOTOOLCHAIN=local go install and regenerates the artefact trees listed under Generated artefact map. The SPDX header pair is re-prepended to every emitted file so the spdx_headers_test.go gate stays green. 4. Wire any new operation ids into the server. The generated ServerInterface under internal/transport/http/v1/server requires a handler method per operation; adding a new operation will fail compilation until internal/transport/http/v1/handlers implements it and internal/transport/http/v1/router.go mounts it. This is deliberate — the compile-time break is the signal that the contract gained a new surface the server must honour. 5. Update tests and docs in the same PR. CLAUDE.md's tests-and-documentation-from-the-start rule applies to every spec change: add unit tests for the handler, integration coverage for the round-trip in tests/integration, and update this document or the README §API Versioning section if the change introduces a new concept (a new auth mode, a new error envelope, a new versioning concept). 6. Commit the generated artefacts. Every file under the artefact trees below is tracked in git; make generate-check runs make generate && git diff --exit-code in CI under the generated-drift job (.github/workflows/ci.yaml) and will refuse the PR if anything would be rewritten.

When in doubt, re-run make openapi-lint and make generate locally before pushing. Both are idempotent — running twice produces the same tree.

Authentication modeling ​

Authentication is expressed exclusively through securitySchemes — never as an Authorization header parameter, which the OpenAPI specification requires tooling to ignore. Three schemes exist:

  • sessionCookie — the plexsphere_session browser cookie (plus the CSRF double-submit on state-changing requests).
  • operatorBearer — an API-token or service-identity bearer carried in Authorization: Bearer.
  • nskBearer — the per-Node Secret Key bearer that authenticates the node-agent surface.

The document declares security: [{sessionCookie}, {operatorBearer}] as the root default. An operation opts out with security: [] exactly when its path is on the authn middleware's bypass allowlist (internal/identity/authn/middleware.DefaultBypass()), and the node-agent operations override to nskBearer. When you add an operation, pick the correct one of these three postures explicitly — an unauthenticated endpoint that silently inherits the root default (or vice versa) is a contract bug.

Contract conventions ​

These conventions keep the surface uniform. They are not all machine-enforced yet (see the follow-ups noted inline); when you add an operation, match them by hand.

  • Nullability — the document is OpenAPI 3.1, so a nullable field is type: ["T", "null"], never the 3.0 nullable: true keyword (which 3.1 validators ignore). The only surviving nullable: true uses are the five NodeStateSnapshot / MeshEdge fields that genuinely emit null and whose 3.1 encoding the pinned oapi-codegen rejects; each carries a DECISION. Every other optional field is absent-when-unset (Go *T + omitempty), and the pagination end-of-stream signal is a uniformly ABSENT next_cursor.
  • Status codes — a create returns 201 with a Location header pointing at a real read URL (or, where no by-id read exists, no header plus a DECISION). Synchronous work is 200, not 202. Back-pressure (rate / concurrency budget) is 429; a state conflict is 409; a malformed request is 400. GET /v1/health is 200/503 on the same body.
  • Error bodies — 4xx/5xx are application/problem+json bound to Problem or PermissionDenied. Both require correlation_id: it carries the id that pairs the response with the server log record for the same failure, and the shared writer stamps it on every body. The same value goes out on the X-Correlation-Id response header, so a caller can quote it without parsing the body. A response example that omits the field fails oas3-valid-media-example in make openapi-lint. The detail is written for the caller: a sentence that names the failure in domain terms. It never carries persistence or infrastructure internals, so no constraint name, no SQLSTATE, no driver message, and no upstream library text belongs in it. The cause goes to the server log under the same correlation id instead; the TestNoRawErrorInProblemDetails workspace gate (make check-problem-details) fails the build when a rendered error reaches a problem writer's arguments. The code field is the machine-readable taxonomy the SPA and CLI switch on and MUST be snake_case; the kebab-case form belongs only to the derived Type URI slug (https://plexsphere.com/errors/<kebab>), never the code value. The TestPlexsphereV1YAML_ProblemCodesAreSnakeCase workspace gate fails the build on a kebab-case code value. (A full machine-readable code registry — every code with its status and a one-line summary — remains a future enhancement; the two Problem conventions in play today, a code-field body and the bootstrap-token Type-URI-only body, would need reconciling first.)
  • One-shot secrets — a value delivered exactly once carries x-plexsphere-once: true and its operation must be on the Spectral write-once allow-list. Inbound secret fields are documented as never-echoed and rely on the split request/response schemas (oapi-codegen turns writeOnly into an optional pointer, so it is deliberately not used).
  • Naming (aspirational, not yet uniform) — path parameters and query parameters SHOULD be snake_case and sub-resource actions SHOULD be /verb subpaths. The spec still mixes {domain_id}-style camelCase params and :verb actions in the older surfaces; converging them is a breaking change tracked for a coordinated rename rather than piecemeal edits.

Additive vs breaking changes ​

The table of additive and breaking cases, with the action each one implies inside /v1, is on the versioning page: Additive versus breaking changes.

The classification is enforced, not advisory. The openapi-compat gate diffs the working-tree spec against the base branch, classifies every change with oasdiff, and fails on a breaking finding that api/openapi/compat-exceptions.yaml does not list with a reason. See The compatibility gate.

Deprecation and sunset signalling ​

A deprecation is declared in the spec, not in a handler. The operation object gains deprecated: true together with x-plexsphere-deprecated-since and, once a removal date is agreed, x-plexsphere-sunset. The composition root reads the markers out of the embedded document at boot and installs a middleware that stamps the RFC 9745 Deprecation and RFC 8594 Sunset headers on every response of the marked operation. No handler calls the header helpers directly.

The marker rules, the Spectral rules that guard them, the wire example, and what does and does not happen at the sunset instant are on the versioning page: Deprecation and sunset.

Generated artefact map ​

make generate is the only supported way to rebuild these files. Every file listed below is committed to git; the generated-drift CI job (.github/workflows/ci.yaml) re-runs the target and refuses the PR if anything would change.

SourceGeneratorOutputConsumer
api/openapi/plexsphere-v1.yamloapi-codegen (tools/openapi/oapi-codegen-types.yaml)pkg/openapi/v1/types/types.gen.goShared Go types imported by both the server and the Go client — the single source of the Health, Version, and error payload shapes.
api/openapi/plexsphere-v1.yamloapi-codegen (tools/openapi/oapi-codegen-server.yaml)internal/transport/http/v1/server/server.gen.goGenerated ServerInterface + chi-flavoured request wiring; implemented by the handlers under internal/transport/http/v1/handlers.
api/openapi/plexsphere-v1.yamloapi-codegen (tools/openapi/oapi-codegen-client.yaml)pkg/openapi/v1/client/client.gen.goTyped Go client used by internal callers and exported for plexd / third-party Go consumers. Imports the shared types module above.

Two secondary artefacts are written by the same make generate step and are also covered by the drift gate:

The Console TypeScript client (console/src/api/schema.d.ts) is generated from the same api/openapi/plexsphere-v1.yaml by openapi-typescript, but it lives outside make generate and the generated-drift gate. It has its own laptop target (make console-generate) and its own CI gate (console-spec-diff), which regenerates the client and fails on a non-empty git diff. The split keeps the Go toolchain off the Node-only Console lane; the drift protection is identical in spirit. Editing the spec therefore requires regenerating both the Go artefacts (make generate) and the Console client (make console-generate) before committing.

If you add a new generator target (for example, a Python client), extend this table in the same PR — otherwise the next contributor will have no way to discover it without grepping the Makefile.

Local commands ​

Every OpenAPI workflow uses one of the following targets. All live in the repo-root Makefile and are safe to run repeatedly.

CommandPurpose
make openapi-lintRun Spectral against the spec using the pinned ruleset. Fails on any rule in tools/openapi/.spectral.yaml; --fail-severity=warn means warnings are gate-blocking too.
make openapi-compatClassify the working-tree spec against the merge-base with PLEXSPHERE_OPENAPI_BASE_REF (default origin/main) using oasdiff. A breaking finding fails until api/openapi/compat-exceptions.yaml lists it with a reason; see The compatibility gate.
make generateRegenerate the artefact trees listed under Generated artefact map. Installs the pinned oapi-codegen via GOTOOLCHAIN=local go install.
make generate-checkRun make generate and assert the working tree is still clean. This is what CI's generated-drift job runs; a red gate here means a committed artefact drifted from the spec.
make testRun every Go workspace test, including the OpenAPI drift gates under tests/workspace (openapi_compat_test.go, openapi_drift_test.go, openapi_lint_test.go, openapi_spec_test.go, openapi_tool_pin_test.go), the router integration tests under tests/integration, and the markdown-link gate in tests/docs.

A typical change sequence is: edit the YAML → make openapi-lint → make generate → run the relevant Go tests → commit the spec and every generated file together. If any step would modify a file you did not expect, read the diff — a surprise usually means the spec change is broader than intended.

Cross-references ​