|
| 1 | +# API Versioning |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +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. |
| 6 | + |
| 7 | +## Nerva's Default: URL Prefix |
| 8 | + |
| 9 | +The default strategy lives in the `api.versioning` block of [`.claude/pipeline.config.json`](../../.claude/pipeline.config.json): |
| 10 | + |
| 11 | +```json |
| 12 | +"versioning": { |
| 13 | + "strategy": "url-prefix", |
| 14 | + "currentVersion": "v1", |
| 15 | + "deprecationHeaderEnabled": true |
| 16 | +} |
| 17 | +``` |
| 18 | + |
| 19 | +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. |
| 20 | + |
| 21 | +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. |
| 22 | + |
| 23 | +### API Version vs. Release Version |
| 24 | + |
| 25 | +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`. |
| 26 | + |
| 27 | +## The Four Strategies |
| 28 | + |
| 29 | +| Strategy | Looks like | Strengths | Weaknesses | |
| 30 | +|----------|-----------|-----------|------------| |
| 31 | +| **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 | |
| 32 | +| **Custom header** | `X-API-Version: 2` | Clean URLs; per-request selection | Invisible in a browser; easy to forget; needs `Vary` for caches | |
| 33 | +| **Content negotiation** | `Accept: application/vnd.nerva.v2+json` | Standards-based; one URL per resource | Verbose; hard to test by hand; needs `Vary: Accept` | |
| 34 | +| **Query parameter** | `GET /users?version=2` | Easy to try in a browser | Pollutes URLs and cache keys; collides with real params; easy to drop | |
| 35 | + |
| 36 | +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. |
| 37 | + |
| 38 | +## Implementing Each in Hono |
| 39 | + |
| 40 | +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. |
| 41 | + |
| 42 | +### URL Prefix |
| 43 | + |
| 44 | +Mount each version as its own sub-app. Running versions side by side is then just two `route()` calls: |
| 45 | + |
| 46 | +```typescript |
| 47 | +// api/src/app.ts |
| 48 | +import { Hono } from "hono"; |
| 49 | +import { todosRoutes } from "./routes/todos"; |
| 50 | +import { usersRoutes } from "./routes/users"; |
| 51 | + |
| 52 | +const v1 = new Hono<AppEnv>(); |
| 53 | +v1.route("/todos", todosRoutes); |
| 54 | +v1.route("/users", usersRoutes); |
| 55 | + |
| 56 | +const app = new Hono<AppEnv>(); |
| 57 | +app.route("/api/v1", v1); |
| 58 | +``` |
| 59 | + |
| 60 | +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: |
| 61 | + |
| 62 | +```typescript |
| 63 | +const v2 = new Hono<AppEnv>(); |
| 64 | +v2.route("/todos", todosRoutesV2); // changed in v2 |
| 65 | +v2.route("/users", usersRoutes); // identical to v1 -- reuse the handler |
| 66 | + |
| 67 | +app.route("/api/v1", v1); |
| 68 | +app.route("/api/v2", v2); |
| 69 | +``` |
| 70 | + |
| 71 | +For a single active version, `basePath` is terser and applies the prefix to every route at once: |
| 72 | + |
| 73 | +```typescript |
| 74 | +const app = new Hono<AppEnv>().basePath("/api/v1"); |
| 75 | +app.route("/todos", todosRoutes); |
| 76 | +app.route("/users", usersRoutes); |
| 77 | +``` |
| 78 | + |
| 79 | +### Custom Header |
| 80 | + |
| 81 | +Resolve the version in middleware, default to the current one, and store it on the context for handlers to read: |
| 82 | + |
| 83 | +```typescript |
| 84 | +// api/src/middleware/version.ts |
| 85 | +import { createMiddleware } from "hono/factory"; |
| 86 | + |
| 87 | +const SUPPORTED = new Set(["v1", "v2"]); |
| 88 | +const CURRENT = "v1"; |
| 89 | + |
| 90 | +export const apiVersion = createMiddleware<AppEnv>(async (c, next) => { |
| 91 | + const raw = c.req.header("X-API-Version") ?? CURRENT; |
| 92 | + const version = raw.startsWith("v") ? raw : `v${raw}`; |
| 93 | + c.set("apiVersion", SUPPORTED.has(version) ? version : CURRENT); |
| 94 | + c.header("Vary", "X-API-Version"); // caches must key on the header |
| 95 | + await next(); |
| 96 | +}); |
| 97 | +``` |
| 98 | + |
| 99 | +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. |
| 100 | + |
| 101 | +### Content Negotiation |
| 102 | + |
| 103 | +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: |
| 104 | + |
| 105 | +```typescript |
| 106 | +// api/src/middleware/version.ts |
| 107 | +import { createMiddleware } from "hono/factory"; |
| 108 | + |
| 109 | +export const negotiateVersion = createMiddleware<AppEnv>(async (c, next) => { |
| 110 | + const accept = c.req.header("Accept") ?? ""; |
| 111 | + const match = accept.match(/application\/vnd\.nerva\.v(\d+)\+json/); |
| 112 | + c.set("apiVersion", match ? `v${match[1]}` : "v1"); |
| 113 | + c.header("Vary", "Accept"); |
| 114 | + await next(); |
| 115 | +}); |
| 116 | +``` |
| 117 | + |
| 118 | +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. |
| 119 | + |
| 120 | +### Query Parameter |
| 121 | + |
| 122 | +The simplest to type and the easiest to misuse: |
| 123 | + |
| 124 | +```typescript |
| 125 | +const version = c.req.query("version") ?? "1"; |
| 126 | +``` |
| 127 | + |
| 128 | +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. |
| 129 | + |
| 130 | +## Deprecation and Sunset Headers |
| 131 | + |
| 132 | +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`: |
| 133 | + |
| 134 | +- **`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. |
| 135 | +- **`Sunset`** ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) -- an HTTP date after which the endpoint may stop responding. |
| 136 | +- **`Link`** -- `rel="successor-version"` points at the replacement, and `rel="deprecation"` points at the migration docs. |
| 137 | + |
| 138 | +A middleware stamps them onto a deprecated route group: |
| 139 | + |
| 140 | +```typescript |
| 141 | +// api/src/middleware/deprecation.ts |
| 142 | +import { createMiddleware } from "hono/factory"; |
| 143 | + |
| 144 | +export function deprecated(options: { sunset: string; successor: string }) { |
| 145 | + return createMiddleware(async (c, next) => { |
| 146 | + await next(); |
| 147 | + c.header("Deprecation", "true"); // RFC 9745 |
| 148 | + c.header("Sunset", options.sunset); // RFC 8594 -- an HTTP date |
| 149 | + c.header("Link", `<${options.successor}>; rel="successor-version"`); |
| 150 | + }); |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +```typescript |
| 155 | +// Mark all of v1's todos as deprecated, sunsetting at a fixed date |
| 156 | +v1.use( |
| 157 | + "/todos/*", |
| 158 | + deprecated({ |
| 159 | + sunset: "Wed, 31 Dec 2025 23:59:59 GMT", |
| 160 | + successor: "/api/v2/todos", |
| 161 | + }), |
| 162 | +); |
| 163 | +``` |
| 164 | + |
| 165 | +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. |
| 166 | + |
| 167 | +## Breaking vs. Non-Breaking Changes |
| 168 | + |
| 169 | +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. |
| 170 | + |
| 171 | +**Non-breaking -- ship in the current version:** |
| 172 | + |
| 173 | +- Add a new endpoint or resource |
| 174 | +- Add a new *optional* request field or query parameter |
| 175 | +- Add a new field to a response body |
| 176 | +- Add a new error `code` for a new failure mode (the [error envelope](./error-handling.md) is itself additive) |
| 177 | +- Loosen validation to accept input you previously rejected |
| 178 | +- Add a value to a response enum, *only if* clients are documented to tolerate unknown values |
| 179 | + |
| 180 | +**Breaking -- requires a new version:** |
| 181 | + |
| 182 | +- Remove or rename a field, endpoint, or query parameter |
| 183 | +- Change a field's type or format (`string` to `number`, a different date format) |
| 184 | +- Make an optional request field required, or add a new required field |
| 185 | +- Tighten validation to reject input you previously accepted |
| 186 | +- Change a response status code, or the meaning of an existing error `code` |
| 187 | +- Change a default -- page size, sort order, or filter behavior |
| 188 | +- Restructure the response envelope or change nesting |
| 189 | +- Add or change an authentication or authorization requirement |
| 190 | +- Remove a value a response enum used to return |
| 191 | + |
| 192 | +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. |
| 193 | + |
| 194 | +## Migration Checklist for a Version Bump |
| 195 | + |
| 196 | +When a change is genuinely breaking, work through these in order: |
| 197 | + |
| 198 | +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. |
| 199 | +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. |
| 200 | +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. |
| 201 | +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. |
| 202 | +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. |
| 203 | +6. **Keep both contracts tested.** Leave v1's contract tests green and add v2's. Both versions are supported until the sunset date. |
| 204 | +7. **Watch the traffic.** Log the requested version on every request so you can see v1 usage decline toward zero. |
| 205 | +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. |
| 206 | + |
| 207 | +## Further Reading |
| 208 | + |
| 209 | +- [Hono: Routing](https://hono.dev/docs/api/routing) -- route grouping and mounting sub-apps |
| 210 | +- [Hono: App and `basePath`](https://hono.dev/docs/api/hono) -- prefixing every route at once |
| 211 | +- [RFC 8594: The Sunset HTTP Header Field](https://www.rfc-editor.org/rfc/rfc8594) -- announcing when a resource stops responding |
| 212 | +- [RFC 9745: The Deprecation HTTP Header Field](https://www.rfc-editor.org/rfc/rfc9745) -- announcing that a resource is deprecated |
| 213 | +- [`.claude/pipeline.config.json`](../../.claude/pipeline.config.json) -- the `api.versioning` block this guide configures |
| 214 | +- [Error Handling](./error-handling.md) -- why adding an error `code` is non-breaking |
| 215 | +- [API Development Standards](./README.md) -- the conventions this guide extends |
0 commit comments