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 7807), 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 whoseoperationIdstarts withIssue— the BootstrapToken plaintext is the canonical user of that flag. Failures here are authored, not generated — fix them in the YAML. - Regenerate the artefacts. Run
make generate. The target installs the pinnedoapi-codegenviaGOTOOLCHAIN=local go installand regenerates the artefact trees listed under Generated artefact map. The SPDX header pair is re-prepended to every emitted file so thespdx_headers_test.gogate stays green. - Wire any new operation ids into the server. The generated
ServerInterfaceunderinternal/transport/http/v1/serverrequires a handler method per operation; adding a new operation will fail compilation untilinternal/transport/http/v1/handlersimplements it andinternal/transport/http/v1/router.gomounts it. This is deliberate — the compile-time break is the signal that the contract gained a new surface the server must honour. - 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 Versioningsection if the change introduces a new concept (a new auth mode, a new error envelope, a new versioning concept). - Commit the generated artefacts. Every file under the artefact trees below is tracked in git;
make generate-checkrunsmake generate && git diff --exit-codein CI under thegenerated-driftjob (.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.
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.
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.