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 7807), 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 whose operationId starts with Issue — the BootstrapToken plaintext is the canonical user of that flag. Failures here are authored, not generated — fix them in the YAML.
  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.

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.

ChangeClassificationAction inside /v1
Add a new operation (path + operationId).AdditiveLand in /v1; add handler + tests in the same PR.
Add an optional request field or query parameter.AdditiveLand in /v1; server must accept omission as the prior default.
Add a new response field.AdditiveLand in /v1; clients must tolerate unknown fields (see the API-versioning policy in README §API Versioning).
Add a new enum value.AdditiveLand in /v1; clients must tolerate unknown values. Document the semantics in the schema description.
Add a new 4xx/5xx shape (still application/problem+json).AdditiveLand in /v1; keep legacy shapes available until a major bump.
Mark a field or operation as deprecated (no removal yet).AdditiveLand 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.BreakingLand 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.BreakingSame as removal — the old name is a distinct wire contract.
Tighten validation (narrower regex, smaller max, required → non-null).BreakingSame — previously-accepted inputs would start failing.
Change a type (integerstring, arrayobject).BreakingSame — the TS paths/operations types no longer compile for unmodified clients.
Change the semantics of a response code for an existing operation.BreakingSame — 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).BreakingTrack 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 9745Deprecation header using the structured-field Date encoding (@<unix-seconds>). since is the instant at which the feature was declared deprecated — typically the release that published the deprecation notice.
  • SetSunset(w, when) writes the RFC 8594Sunset header using the RFC 1123 IMF-fixdate form (Thu, 31 Dec 2026 23:59:59 GMT) that RFC 7231 §7.1.1.1 mandates. when is 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 GMT

The 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.

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:

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 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_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-lintmake 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