Appearance
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.
- Edit the spec first. Open
api/openapi/plexsphere-v1.yamland add, rename, or remove operations, request/response schemas, parameters, or error shapes. The spec declaresopenapi: 3.1.0andinfo.version: v1; neither value is editable inside a major version (raising either one is part of a/vN+1rollout). Operation IDs are PascalCase and stable — Spectral rejectscamelCase, and a rename is always a breaking change (the Go client and TSoperationsmap key off the id). - Lint the spec. Run
make openapi-lintto invoke the pinned Spectral ruleset (tools/openapi/.spectral.yaml). The ruleset layers six plexsphere-specific rules on top ofspectral:oas: PascalCase operation IDs, mandatory response schemas,application/problem+jsonas the only acceptable media type for 4xx/5xx responses (RFC 9457), every problem+json body$ref-ing the sharedProblemorPermissionDeniedenvelope , and the paired write-once gatesplexsphere-write-once-non-post-forbidden/plexsphere-write-once-post-must-be-issue-responsethat pin thex-plexsphere-once: truevendor 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— theplexsphere_sessionbrowser cookie (plus the CSRF double-submit on state-changing requests).operatorBearer— an API-token or service-identity bearer carried inAuthorization: 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.0nullable: truekeyword (which 3.1 validators ignore). The only survivingnullable: trueuses are the fiveNodeStateSnapshot/MeshEdgefields that genuinely emitnulland 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 ABSENTnext_cursor. - Status codes — a create returns
201with aLocationheader pointing at a real read URL (or, where no by-id read exists, no header plus a DECISION). Synchronous work is200, not202. Back-pressure (rate / concurrency budget) is429; a state conflict is409; a malformed request is400.GET /v1/healthis200/503on the same body. - Error bodies — 4xx/5xx are
application/problem+jsonbound toProblemorPermissionDenied. Both requirecorrelation_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 theX-Correlation-Idresponse header, so a caller can quote it without parsing the body. A response example that omits the field failsoas3-valid-media-exampleinmake openapi-lint. Thedetailis 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; theTestNoRawErrorInProblemDetailsworkspace gate (make check-problem-details) fails the build when a rendered error reaches a problem writer's arguments. Thecodefield 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 thecodevalue. TheTestPlexsphereV1YAML_ProblemCodesAreSnakeCaseworkspace gate fails the build on a kebab-casecodevalue. (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, acode-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: trueand 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 turnswriteOnlyinto 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
/verbsubpaths. The spec still mixes{domain_id}-style camelCase params and:verbactions 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.
| Source | Generator | Output | Consumer |
|---|---|---|---|
api/openapi/plexsphere-v1.yaml | oapi-codegen (tools/openapi/oapi-codegen-types.yaml) | pkg/openapi/v1/types/types.gen.go | Shared 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.yaml | oapi-codegen (tools/openapi/oapi-codegen-server.yaml) | internal/transport/http/v1/server/server.gen.go | Generated ServerInterface + chi-flavoured request wiring; implemented by the handlers under internal/transport/http/v1/handlers. |
api/openapi/plexsphere-v1.yaml | oapi-codegen (tools/openapi/oapi-codegen-client.yaml) | pkg/openapi/v1/client/client.gen.go | Typed 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:
internal/transport/http/v1/handlers/plexsphere-v1.yaml— a byte-equal copy of the spec embedded via//go:embedso theGetOpenAPIhandler can serve it as JSON.- The SPDX header pair prepended to every generated file by the
make generatetarget so thetests/workspace/spdx_headers_test.gogate stays green without a per-template customisation ofoapi-codegen.
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.
| Command | Purpose |
|---|---|
make openapi-lint | Run 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-compat | Classify 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 generate | Regenerate the artefact trees listed under Generated artefact map. Installs the pinned oapi-codegen via GOTOOLCHAIN=local go install. |
make generate-check | Run 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 test | Run 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
- README §API Versioning & Compatibility — policy: additive vs. breaking, overlap windows, deprecation signalling from the product perspective.
- RFC 9745 — The Deprecation HTTP Response Header — encoding of
SetDeprecation. - RFC 8594 — The Sunset HTTP Response Header — encoding of
SetSunset. docs/contributing/layout.md— bounded-context map; the generated artefact trees (api/openapi/,pkg/openapi/v1/) are listed there alongside theinternal/modules.docs/contributing/toolchain.md— howoapi-codegenand Spectral are pinned and upgraded.docs/contributing/api-docs.md— the embedded ReDoc UI mounted at/v1/docs: vendored asset layout, thescripts/update-redoc.shupgrade procedure, the CSP /X-Content-Type-Options/Cache-Controlposture, and the authn/authz bypass entries.