From fe3c07ec080a867ce2278276a0e68b1ee68f7779 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Sat, 13 Jun 2026 14:38:24 -0400 Subject: [PATCH] docs: add API versioning strategy guide (#24) Add docs/api-development/api-versioning.md covering how to version a Nerva/Hono API and evolve it without breaking consumers: - Nerva's default (URL prefix /api/v1/) and the api.versioning block of pipeline.config.json that configures it - API version (the contract) vs. release version (the build) - all four strategies -- URL prefix, custom header, content negotiation, query parameter -- with trade-offs and a Hono implementation for each - Deprecation (RFC 9745) and Sunset (RFC 8594) headers, gated by deprecationHeaderEnabled, with a Hono middleware - a breaking vs. non-breaking change reference - a migration checklist for version bumps Linked from docs/api-development/README.md via a new API Versioning section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api-development/README.md | 6 + docs/api-development/api-versioning.md | 215 +++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 docs/api-development/api-versioning.md diff --git a/docs/api-development/README.md b/docs/api-development/README.md index ed9b2a1..4c5b555 100644 --- a/docs/api-development/README.md +++ b/docs/api-development/README.md @@ -347,6 +347,12 @@ All error responses follow the same shape -- a single top-level `error` object: See the [error handling guide](./error-handling.md) for the full format definition, error code constants, typed error classes, the global `app.onError()` handler, per-category examples, and how the format maps to RFC 9457 Problem Details. +## API Versioning + +Nerva versions APIs by URL prefix -- `/api/v1/...` -- configured in the `api.versioning` block of `.claude/pipeline.config.json` (`strategy: "url-prefix"`, `currentVersion: "v1"`). Add endpoints and optional fields within a version; bump to `/api/v2` only for breaking changes, and warn consumers first with `Deprecation` and `Sunset` headers. + +See the [API versioning guide](./api-versioning.md) for all four strategies (URL prefix, header, content negotiation, query parameter) and how to implement each in Hono, the deprecation and sunset headers, the breaking vs. non-breaking reference, and a migration checklist for version bumps. + ## Performance ### Query Optimization diff --git a/docs/api-development/api-versioning.md b/docs/api-development/api-versioning.md new file mode 100644 index 0000000..0755b47 --- /dev/null +++ b/docs/api-development/api-versioning.md @@ -0,0 +1,215 @@ +# API Versioning + +How to version a Nerva API and evolve it without breaking consumers -- the four strategies and their trade-offs, how to implement each in Hono, the deprecation and sunset headers that warn clients before a version disappears, which changes force a new version, and a checklist for shipping one. + +Versioning is one of the most debated topics in API design, but the goal is narrow: change the API over time without breaking the code already calling it. The cheapest version bump is the one you avoid by making changes additively. When a change genuinely can't be additive, a version boundary lets old and new clients coexist while consumers migrate on their own schedule. Nerva picks an opinionated default and configures it in one place; this guide covers that default, the alternatives, and how to evolve safely whichever you choose. + +## Nerva's Default: URL Prefix + +The default strategy lives in the `api.versioning` block of [`.claude/pipeline.config.json`](../../.claude/pipeline.config.json): + +```json +"versioning": { + "strategy": "url-prefix", + "currentVersion": "v1", + "deprecationHeaderEnabled": true +} +``` + +This puts the version in the path -- `GET /api/v1/users` -- with the prefix driven by `routes.prefix` (`/api/v1`). The pipeline generates routes under that prefix, and the health check reports the running version (`api.healthCheck.includeVersion`), so `GET /health` tells you which version is live. + +URL-prefix is the default because it is the most visible option. The version shows up in server logs, in `curl`, in browser history, and in cache keys, so "which version did that client call?" never requires guessing. It needs no special client configuration, and routing on a path prefix is trivial. The cost is that the version rides along in every path and you version the API as a whole rather than per resource -- acceptable trade-offs for most APIs, and the reason most public APIs settle here. + +### API Version vs. Release Version + +Keep two numbers separate. The **API version** (`v1`) is the contract: the shapes and behavior consumers depend on. The **release version** (a semver like `1.4.2`, surfaced as `APP_VERSION` at `/` and `/health`) is the deployed build. You ship many releases inside one API version -- bug fixes and additive features bump the release, not the contract. Only a breaking change bumps `v1` to `v2`. + +## The Four Strategies + +| Strategy | Looks like | Strengths | Weaknesses | +|----------|-----------|-----------|------------| +| **URL prefix** (default) | `GET /api/v1/users` | Visible in logs, `curl`, and caches; trivial to route; no client setup | Version in every path; whole-API granularity | +| **Custom header** | `X-API-Version: 2` | Clean URLs; per-request selection | Invisible in a browser; easy to forget; needs `Vary` for caches | +| **Content negotiation** | `Accept: application/vnd.nerva.v2+json` | Standards-based; one URL per resource | Verbose; hard to test by hand; needs `Vary: Accept` | +| **Query parameter** | `GET /users?version=2` | Easy to try in a browser | Pollutes URLs and cache keys; collides with real params; easy to drop | + +The strategies are not mutually exclusive -- an API can accept a header and fall back to a default -- but pick one as the contract and document it. Mixing them silently is how a client ends up on a version nobody intended. + +## Implementing Each in Hono + +The examples assume the `AppEnv` type from the [error handling guide](./error-handling.md), extended with an `apiVersion` context variable where a strategy needs one. + +### URL Prefix + +Mount each version as its own sub-app. Running versions side by side is then just two `route()` calls: + +```typescript +// api/src/app.ts +import { Hono } from "hono"; +import { todosRoutes } from "./routes/todos"; +import { usersRoutes } from "./routes/users"; + +const v1 = new Hono(); +v1.route("/todos", todosRoutes); +v1.route("/users", usersRoutes); + +const app = new Hono(); +app.route("/api/v1", v1); +``` + +When a `v2` arrives, re-implement only what changed and reuse the rest -- a v2 sub-app can mount v1's unchanged route groups directly: + +```typescript +const v2 = new Hono(); +v2.route("/todos", todosRoutesV2); // changed in v2 +v2.route("/users", usersRoutes); // identical to v1 -- reuse the handler + +app.route("/api/v1", v1); +app.route("/api/v2", v2); +``` + +For a single active version, `basePath` is terser and applies the prefix to every route at once: + +```typescript +const app = new Hono().basePath("/api/v1"); +app.route("/todos", todosRoutes); +app.route("/users", usersRoutes); +``` + +### Custom Header + +Resolve the version in middleware, default to the current one, and store it on the context for handlers to read: + +```typescript +// api/src/middleware/version.ts +import { createMiddleware } from "hono/factory"; + +const SUPPORTED = new Set(["v1", "v2"]); +const CURRENT = "v1"; + +export const apiVersion = createMiddleware(async (c, next) => { + const raw = c.req.header("X-API-Version") ?? CURRENT; + const version = raw.startsWith("v") ? raw : `v${raw}`; + c.set("apiVersion", SUPPORTED.has(version) ? version : CURRENT); + c.header("Vary", "X-API-Version"); // caches must key on the header + await next(); +}); +``` + +A handler then shapes its response by `c.get("apiVersion")`. The URL stays clean, but the version is invisible to anyone reading a log line or a browser address bar, and any cache in front of the API must `Vary` on the header or it will serve one version's body to a client asking for another. + +### Content Negotiation + +Content negotiation is the formal version of the header approach: the client asks for a versioned media type in `Accept`, and the server replies with that type. Use a vendor media type -- `application/vnd.nerva.v2+json` -- so the version travels with the representation: + +```typescript +// api/src/middleware/version.ts +import { createMiddleware } from "hono/factory"; + +export const negotiateVersion = createMiddleware(async (c, next) => { + const accept = c.req.header("Accept") ?? ""; + const match = accept.match(/application\/vnd\.nerva\.v(\d+)\+json/); + c.set("apiVersion", match ? `v${match[1]}` : "v1"); + c.header("Vary", "Accept"); + await next(); +}); +``` + +This is the most standards-aligned option and keeps one URL per resource, which some REST purists prefer. It is also the most verbose and the hardest to exercise by hand -- every test and `curl` must set the right `Accept` header, and the same `Vary: Accept` caching rule applies. See the [CORS guide](./cors-configuration.md) for exposing custom headers to browser clients. + +### Query Parameter + +The simplest to type and the easiest to misuse: + +```typescript +const version = c.req.query("version") ?? "1"; +``` + +A `?version=2` is trivial to try in a browser, but it pollutes every URL, competes with real query parameters, leaks into cache keys inconsistently, and is the easiest to forget -- a dropped parameter silently routes to the default version. Reach for it only for quick internal tools, not a public contract. + +## Deprecation and Sunset Headers + +A version rarely disappears at once; it is announced, deprecated, and finally removed. Two standard response headers carry that timeline, controlled by `deprecationHeaderEnabled` in `api.versioning`: + +- **`Deprecation`** ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) -- the endpoint is deprecated. The common form is `Deprecation: true`; the RFC also allows a date to announce a deprecation that takes effect in the future. +- **`Sunset`** ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) -- an HTTP date after which the endpoint may stop responding. +- **`Link`** -- `rel="successor-version"` points at the replacement, and `rel="deprecation"` points at the migration docs. + +A middleware stamps them onto a deprecated route group: + +```typescript +// api/src/middleware/deprecation.ts +import { createMiddleware } from "hono/factory"; + +export function deprecated(options: { sunset: string; successor: string }) { + return createMiddleware(async (c, next) => { + await next(); + c.header("Deprecation", "true"); // RFC 9745 + c.header("Sunset", options.sunset); // RFC 8594 -- an HTTP date + c.header("Link", `<${options.successor}>; rel="successor-version"`); + }); +} +``` + +```typescript +// Mark all of v1's todos as deprecated, sunsetting at a fixed date +v1.use( + "/todos/*", + deprecated({ + sunset: "Wed, 31 Dec 2025 23:59:59 GMT", + successor: "/api/v2/todos", + }), +); +``` + +These headers are how a well-behaved client learns to migrate without a human reading a changelog: monitoring can alert on any `Deprecation` header in a response, and the `Sunset` date gives a hard deadline. Set the date once you have a successor and a migration path, not before. + +## Breaking vs. Non-Breaking Changes + +The version question reduces to a single test: would this change break code that works today? If not, ship it in the current version. If so, it needs a new one. + +**Non-breaking -- ship in the current version:** + +- Add a new endpoint or resource +- Add a new *optional* request field or query parameter +- Add a new field to a response body +- Add a new error `code` for a new failure mode (the [error envelope](./error-handling.md) is itself additive) +- Loosen validation to accept input you previously rejected +- Add a value to a response enum, *only if* clients are documented to tolerate unknown values + +**Breaking -- requires a new version:** + +- Remove or rename a field, endpoint, or query parameter +- Change a field's type or format (`string` to `number`, a different date format) +- Make an optional request field required, or add a new required field +- Tighten validation to reject input you previously accepted +- Change a response status code, or the meaning of an existing error `code` +- Change a default -- page size, sort order, or filter behavior +- Restructure the response envelope or change nesting +- Add or change an authentication or authorization requirement +- Remove a value a response enum used to return + +The dividing line is the *tolerant reader* principle: design servers to add fields additively and clients to ignore fields they don't recognize. An API and its consumers that both follow it turn most evolution into non-breaking changes, and a new version becomes a rare event rather than a quarterly chore. + +## Migration Checklist for a Version Bump + +When a change is genuinely breaking, work through these in order: + +1. **Confirm it is breaking.** Check the table above. If the change is additive, stop here and ship it in the current version -- do not create a version for a change that doesn't need one. +2. **Stand up the new version's route tree.** Add `/api/v2` as its own sub-app and leave `/api/v1` untouched. Both serve traffic during the transition. +3. **Reuse, don't copy.** Re-implement only the handlers and services whose behavior changed; mount the unchanged route groups from v1. Copying the whole API doubles the surface you maintain. +4. **Update the spec and regenerate.** Add the v2 paths to the OpenAPI spec, then regenerate the types and client with `./scripts/generate-client.sh` so consumers get typed access to v2. +5. **Deprecate v1.** With `deprecationHeaderEnabled` on, attach `Deprecation`, `Sunset`, and `Link` headers to v1, pick a sunset date, and announce it in the changelog and to known consumers. +6. **Keep both contracts tested.** Leave v1's contract tests green and add v2's. Both versions are supported until the sunset date. +7. **Watch the traffic.** Log the requested version on every request so you can see v1 usage decline toward zero. +8. **Remove v1 after sunset.** Once the date has passed and traffic is negligible, delete v1's routes, services, tests, and spec paths in one cleanup. + +## Further Reading + +- [Hono: Routing](https://hono.dev/docs/api/routing) -- route grouping and mounting sub-apps +- [Hono: App and `basePath`](https://hono.dev/docs/api/hono) -- prefixing every route at once +- [RFC 8594: The Sunset HTTP Header Field](https://www.rfc-editor.org/rfc/rfc8594) -- announcing when a resource stops responding +- [RFC 9745: The Deprecation HTTP Header Field](https://www.rfc-editor.org/rfc/rfc9745) -- announcing that a resource is deprecated +- [`.claude/pipeline.config.json`](../../.claude/pipeline.config.json) -- the `api.versioning` block this guide configures +- [Error Handling](./error-handling.md) -- why adding an error `code` is non-breaking +- [API Development Standards](./README.md) -- the conventions this guide extends