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 rule of thumb from README §API Versioning & Compatibility is that additive changes stay within /v1 and breaking changes force /vN+1. The table below is the working reference for the difference, applied to the shape of this spec.
| Change | Classification | Action inside /v1 |
|---|---|---|
| Add a new operation (path + operationId). | Additive | Land in /v1; add handler + tests in the same PR. |
| Add an optional request field or query parameter. | Additive | Land in /v1; server must accept omission as the prior default. |
| Add a new response field. | Additive | Land in /v1; clients must tolerate unknown fields (see the API-versioning policy in README §API Versioning). |
| Add a new enum value. | Additive | Land in /v1; clients must tolerate unknown values. Document the semantics in the schema description. |
Add a new 4xx/5xx shape (still application/problem+json). | Additive | Land in /v1; keep legacy shapes available until a major bump. |
| Mark a field or operation as deprecated (no removal yet). | Additive | Land in /v1; set deprecated: true in the spec and emit Deprecation / Sunset headers — see Deprecation and sunset signalling. |
| Remove an operation, field, or enum value. | Breaking | Land in /vN+1 only; the /v1 router keeps serving the old contract in parallel for the overlap window. |
| Rename an operationId, field, or path segment. | Breaking | Same as removal — the old name is a distinct wire contract. |
| Tighten validation (narrower regex, smaller max, required → non-null). | Breaking | Same — previously-accepted inputs would start failing. |
Change a type (integer → string, array → object). | Breaking | Same — the TS paths/operations types no longer compile for unmodified clients. |
| Change the semantics of a response code for an existing operation. | Breaking | Same — clients switch on status code and will mis-route. |
Change the shape of an SSE event envelope (not in /v1/openapi.json today but governed by the same rule). | Breaking | Track alongside the REST major bump; the two share a version. |
The guiding intuition: if a well-behaved existing client — one that tolerates unknown fields and unknown enum values — can still round-trip every request/response against the new spec, the change is additive. If any such client would break or silently misinterpret, the change is breaking and must be held for the next major version.
Deprecation and sunset signalling
Endpoints and fields that are slated for removal in the next major version are advertised on every response via two RFC-defined headers. The handlers reach for the helpers in internal/transport/http/v1/apiversion; they centralise the encoding so a typo cannot slip past review.
SetDeprecation(w, since)writes the RFC 9745Deprecationheader using the structured-field Date encoding (@<unix-seconds>).sinceis the instant at which the feature was declared deprecated — typically the release that published the deprecation notice.SetSunset(w, when)writes the RFC 8594Sunsetheader using the RFC 1123 IMF-fixdate form (Thu, 31 Dec 2026 23:59:59 GMT) that RFC 7231 §7.1.1.1 mandates.whenis the instant at which the feature will be removed; it is always>= since.
Both helpers reject a zero time.Time (ErrZeroTime) rather than advertise year 0001 to clients — advertising a nonsense date is worse than omitting the header outright, and the explicit error makes the misuse visible at call time.
Worked example — deprecating GetVersion
Suppose /v1/version is being folded into /v1/openapi.json in /v2 and will be removed after a 12-month overlap. A handler that keeps serving the endpoint during the overlap window looks like:
go
// File: internal/transport/http/v1/handlers/version.go
package handlers
import (
"net/http"
"time"
"github.com/plexsphere/plexsphere/internal/transport/http/v1/apiversion"
)
// announcementDate is the instant at which the deprecation was
// published in the changelog (RFC 9745 §1).
var announcementDate = time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC)
// removalDate is the instant at which the /v1 implementation of
// GetVersion will be removed. Clients have the full window between
// announcementDate and removalDate to migrate (RFC 8594 §3).
var removalDate = time.Date(2027, time.April, 1, 0, 0, 0, 0, time.UTC)
func (h *Handlers) GetVersion(w http.ResponseWriter, r *http.Request) {
if err := apiversion.SetDeprecation(w, announcementDate); err != nil {
// ErrZeroTime is the only documented failure; a misconfigured
// constant is a programming error, not a runtime one.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := apiversion.SetSunset(w, removalDate); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// ... serve the Build payload exactly as before.
h.writeVersion(w, r)
}On the wire, every response from this handler carries:
http
X-Plexsphere-API-Version: current=v1, latest=v1
Deprecation: @1775001600
Sunset: Thu, 01 Apr 2027 00:00:00 GMTThe X-Plexsphere-API-Version header comes from the /v1 middleware (apiversion.Middleware) and is present on every /v1 response independently of the two deprecation headers; clients use it to detect that a newer major exists even when the current endpoint is not yet deprecated.
In the OpenAPI document the same operation gains deprecated: true so spec-driven tooling (Stoplight Elements, Redocly, the generated TS types) renders the warning inline.
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 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_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.