Versioning
Change an API without breaking the clients already using it.
What it is
Adding a field is safe; renaming or removing one breaks every client that relied on it. So breaking changes ship as a new version next to the old one. Version 2 of a task here replaces done: true with status: "done" and adds a self link. Clients choose it either in the URL (/api/v2/tasks: obvious and easy to cache) or with a header (API-Version: 2 on the same URL: cleaner URLs, but caches must be told with Vary). When an old version is on its way out, its responses say so: Deprecation (RFC 9745), Sunset (RFC 8594) and a Link to its successor.
When to use it
- Before any change that renames, removes or retypes a field
- Public APIs whose clients you can't update yourself
- Mobile apps, where old versions stay installed for years
One task, two versions
A task to look at in both shapes.
Press Send to see the response.
Version 0 never existed before this page: it's kept as a demonstration of what a retiring version's responses look like. Its sunset date is fictional, and it will keep working.
Show code · src/api/routes/tasks.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import type { Context } from "hono";
import { and, desc, eq, notInArray, sql, type SQL } from "drizzle-orm";
import { db } from "../db/client";
import { type TaskRow, tasks } from "../db/schema";
import { decodeCursor, encodeCursor, etagFor, failsIfMatch, isNotModified } from "../http";
import { Problem, problem } from "../problem";
import { validationHook } from "../validation";
// A classic REST resource. The URL names the thing (/tasks, /tasks/{id}); the
// method says what to do with it; the status code says how it went.
const MAX_TASKS = 200; // the live lab is public: keep only the newest 200
// ---- Representations --------------------------------------------------------
const Task = z
.object({ id: z.uuid(), title: z.string(), done: z.boolean(), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime() })
.openapi("Task");
// Version 2 of the same resource: a status enum instead of a boolean (room for
// "archived" later without another breaking change) and a link to itself.
const TaskV2 = z
.object({
id: z.uuid(),
title: z.string(),
status: z.enum(["open", "done"]),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
links: z.object({ self: z.string() }),
})
.openapi("TaskV2");
const toTask = (row: TaskRow): z.infer<typeof Task> => ({
id: row.id, title: row.title, done: row.done, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
});
const toTaskV2 = (row: TaskRow): z.infer<typeof TaskV2> => ({
id: row.id, title: row.title, status: row.done ? "done" : "open",
createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), links: { self: `/api/v2/tasks/${row.id}` },
});
// strictObject: unknown fields are an error, not silently ignored, so a typo
// like "titel" is caught instead of creating an untitled task.
const NewTask = z
.strictObject({
title: z.string().trim().min(1, "Give the task a title").max(200, "Keep the title under 200 characters"),
done: z.boolean().optional(),
})
.openapi("NewTask", { example: { title: "Learn about status codes" } });
const TaskPatch = NewTask.partial()
.refine((p) => Object.keys(p).length > 0, "Send at least one field to change")
.openapi("TaskPatch", { example: { done: true } });
const IdParam = z.object({ id: z.uuid().openapi({ param: { name: "id", in: "path" }, example: "5f0c1d2e-8a4b-4c6d-9e0f-1a2b3c4d5e6f" }) });
const ListQuery = z
.object({
done: z.enum(["true", "false"]).optional().openapi({ description: "Only finished (true) or open (false) tasks" }),
limit: z.coerce.number().int().min(1).max(50).default(20).openapi({ description: "Page size, 1–50" }),
cursor: z.string().max(200).optional().openapi({ description: "nextCursor from the previous page" }),
offset: z.coerce.number().int().min(0).max(1000).optional().openapi({ description: "Offset pagination, for comparison: skips rows" }),
})
.refine((q) => !(q.cursor && q.offset !== undefined), { message: "Use cursor or offset, not both", path: ["offset"] });
const page = <T extends z.ZodType>(item: T, name: string) =>
z.object({
items: z.array(item),
nextCursor: z.string().nullable().openapi({ description: "Pass as ?cursor= for the next page; null on the last page" }),
nextOffset: z.number().int().optional().openapi({ description: "Only in offset mode" }),
}).openapi(name);
const TaskPage = page(Task, "TaskPage");
const TaskPageV2 = page(TaskV2, "TaskPageV2");
const json = <T extends z.ZodType>(schema: T, description: string) => ({ content: { "application/json": { schema } }, description });
const problemResponse = (description: string) => ({ content: { "application/problem+json": { schema: Problem } }, description });
const etagHeaders = z.object({ ETag: z.string(), "Cache-Control": z.string() });
// ---- Queries -----------------------------------------------------------------
type Cursor = { c: string; i: string }; // created_at and id of the last row shown
/**
* Keyset ("cursor") pagination: "the 20 rows after this one", using the
* (created_at, id) index. Unlike OFFSET, inserting or deleting rows while
* someone pages through can't make them skip or repeat a row, and page 1,000
* is as fast as page 1. id breaks ties between rows created in the same
* millisecond.
*/
async function listPage(c: Context, query: z.infer<typeof ListQuery>, basePath: string) {
const filters: SQL[] = [];
if (query.done !== undefined) filters.push(eq(tasks.done, query.done === "true"));
if (query.cursor) {
const cur = decodeCursor<Cursor>(query.cursor);
if (!cur?.c || !cur?.i || Number.isNaN(Date.parse(cur.c))) return null;
filters.push(sql`(${tasks.createdAt}, ${tasks.id}) < (${cur.c}::timestamptz, ${cur.i}::uuid)`);
}
let q = (await db()).select().from(tasks).where(and(...filters))
.orderBy(desc(tasks.createdAt), desc(tasks.id)).limit(query.limit + 1).$dynamic();
if (query.offset !== undefined) q = q.offset(query.offset);
const rows = await q;
const more = rows.length > query.limit;
const shown = rows.slice(0, query.limit);
const last = shown.at(-1);
const nextCursor = more && last && query.offset === undefined ? encodeCursor({ c: last.createdAt.toISOString(), i: last.id }) : null;
// RFC 8288 Link header: generic clients can follow rel="next" without
// knowing this API's body format.
if (nextCursor) {
const next = new URLSearchParams({ ...(query.done ? { done: query.done } : {}), limit: String(query.limit), cursor: nextCursor });
c.header("Link", `<${basePath}?${next}>; rel="next"`);
}
return { rows: shown, nextCursor, nextOffset: query.offset !== undefined && more ? query.offset + query.limit : undefined };
}
const findTask = async (id: string) => (await (await db()).select().from(tasks).where(eq(tasks.id, id)))[0];
const missing = (c: Context, id: string) => problem(c, 404, "not-found", { detail: `There's no task ${id}.` });
const badCursor = (c: Context) => problem(c, 422, "validation", { detail: "The cursor isn't one this API issued.", errors: [{ path: "query.cursor", message: "Invalid cursor" }] });
// ---- Routes -------------------------------------------------------------------
export const tasksApi = new OpenAPIHono({ defaultHook: validationHook });
tasksApi.openapi(
createRoute({
method: "get", path: "/tasks", tags: ["Tasks"], summary: "List tasks, a page at a time",
request: { query: ListQuery },
responses: {
200: { ...json(TaskPage, "The newest tasks first. Link: <…>; rel=\"next\" when there's more"), headers: etagHeaders },
304: { description: "Not modified: the page is unchanged since the ETag you sent" },
422: problemResponse("Invalid query or cursor"),
},
}),
async (c) => {
const result = await listPage(c, c.req.valid("query"), "/api/tasks");
if (!result) return badCursor(c);
const body = { items: result.rows.map(toTask), nextCursor: result.nextCursor, ...(result.nextOffset !== undefined ? { nextOffset: result.nextOffset } : {}) };
const etag = await etagFor(body);
c.header("ETag", etag);
c.header("Cache-Control", "no-cache"); // may be stored, but must be revalidated before reuse
return isNotModified(c, etag) ? c.body(null, 304) : c.json(body, 200);
},
);
tasksApi.openapi(
createRoute({
method: "post", path: "/tasks", tags: ["Tasks"], summary: "Create a task",
request: { body: { content: { "application/json": { schema: NewTask } }, required: true } },
responses: {
201: { ...json(Task, "Created. The Location header holds the new task's URL"), headers: z.object({ Location: z.string(), ETag: z.string() }) },
400: problemResponse("The body isn't JSON"),
422: problemResponse("The JSON doesn't match NewTask"),
},
}),
async (c) => {
const input = c.req.valid("json");
const d = await db();
// createdAt is set here, with millisecond precision, rather than by
// Postgres (microseconds): cursors carry it as an ISO string, which must
// round-trip exactly.
const [row] = await d.insert(tasks).values({ title: input.title, done: input.done ?? false, createdAt: new Date(), updatedAt: new Date() }).returning();
const keep = d.select({ id: tasks.id }).from(tasks).orderBy(desc(tasks.createdAt), desc(tasks.id)).limit(MAX_TASKS);
await d.delete(tasks).where(notInArray(tasks.id, keep));
const task = toTask(row);
c.header("Location", `/api/tasks/${row.id}`);
c.header("ETag", await etagFor(task));
return c.json(task, 201);
},
);
tasksApi.openapi(
createRoute({
method: "get", path: "/tasks/{id}", tags: ["Tasks"], summary: "Get one task (API-Version: 1 or 2)",
request: {
params: IdParam,
headers: z.object({ "api-version": z.string().optional().openapi({ description: "1 (default) or 2: which representation to return" }) }),
},
responses: {
200: { ...json(z.union([Task, TaskV2]), "The task, as Task (version 1) or TaskV2"), headers: etagHeaders.extend({ "API-Version": z.string(), Vary: z.string() }) },
304: { description: "Not modified: your copy (If-None-Match) is current" },
400: problemResponse("Unknown API-Version"),
404: problemResponse("No task with that id"),
422: problemResponse("The id isn't a UUID"),
},
}),
async (c) => {
// Header-based versioning: same URL, the client picks the shape. Vary tells
// caches that the response depends on this header.
const version = c.req.header("api-version") ?? "1";
c.header("Vary", "API-Version");
if (version !== "1" && version !== "2") return problem(c, 400, "unsupported-version", { detail: `API-Version must be 1 or 2, not "${version}".` });
const { id } = c.req.valid("param");
const row = await findTask(id);
if (!row) return missing(c, id);
const body = version === "2" ? toTaskV2(row) : toTask(row);
const etag = await etagFor(body);
c.header("ETag", etag);
c.header("Cache-Control", "no-cache");
c.header("API-Version", version);
return isNotModified(c, etag) ? c.body(null, 304) : c.json(body, 200);
},
);
tasksApi.openapi(
createRoute({
method: "patch", path: "/tasks/{id}", tags: ["Tasks"], summary: "Change some fields of a task (If-Match for safe concurrent edits)",
request: {
params: IdParam,
headers: z.object({ "if-match": z.string().optional().openapi({ description: "The ETag you last saw. 412 if the task has changed since" }) }),
body: { content: { "application/json": { schema: TaskPatch } }, required: true },
},
responses: {
200: { ...json(Task, "The updated task, with its new ETag"), headers: z.object({ ETag: z.string() }) },
404: problemResponse("No task with that id"),
412: problemResponse("If-Match didn't match: someone changed the task first"),
422: problemResponse("Invalid fields"),
},
}),
async (c) => {
const { id } = c.req.valid("param");
const patch = c.req.valid("json");
const current = await findTask(id);
if (!current) return missing(c, id);
if (failsIfMatch(c, await etagFor(toTask(current)))) return problem(c, 412, "precondition-failed", { detail: "Fetch the task again, reapply your change, and send its new ETag." });
// The WHERE on updated_at makes the check and the write one atomic step: a
// change that lands between our read and this update makes it match nothing.
const [row] = await (await db()).update(tasks).set({ ...patch, updatedAt: new Date() })
.where(and(eq(tasks.id, id), eq(tasks.updatedAt, current.updatedAt))).returning();
if (!row) return problem(c, 412, "precondition-failed", { detail: "The task changed while this update was in flight." });
const task = toTask(row);
c.header("ETag", await etagFor(task));
return c.json(task, 200);
},
);
tasksApi.openapi(
createRoute({
method: "delete", path: "/tasks/{id}", tags: ["Tasks"], summary: "Delete a task",
request: { params: IdParam, headers: z.object({ "if-match": z.string().optional() }) },
responses: {
204: { description: "Deleted. No body" },
404: problemResponse("No task with that id"),
412: problemResponse("If-Match didn't match"),
},
}),
async (c) => {
const { id } = c.req.valid("param");
const current = await findTask(id);
if (!current) return missing(c, id);
if (failsIfMatch(c, await etagFor(toTask(current)))) return problem(c, 412, "precondition-failed", { detail: "The task changed since you read it." });
await (await db()).delete(tasks).where(and(eq(tasks.id, id), eq(tasks.updatedAt, current.updatedAt)));
return c.body(null, 204);
},
);
// ---- Version 2 in the URL, and a deprecated version 0 -----------------------
tasksApi.openapi(
createRoute({
method: "get", path: "/v2/tasks", tags: ["Tasks v2"], summary: "List tasks in the version 2 shape",
request: { query: ListQuery },
responses: { 200: json(TaskPageV2, "Like /api/tasks, as TaskV2"), 422: problemResponse("Invalid query or cursor") },
}),
async (c) => {
const result = await listPage(c, c.req.valid("query"), "/api/v2/tasks");
if (!result) return badCursor(c);
return c.json({ items: result.rows.map(toTaskV2), nextCursor: result.nextCursor, ...(result.nextOffset !== undefined ? { nextOffset: result.nextOffset } : {}) }, 200);
},
);
tasksApi.openapi(
createRoute({
method: "get", path: "/v2/tasks/{id}", tags: ["Tasks v2"], summary: "Get one task in the version 2 shape",
request: { params: IdParam },
responses: { 200: json(TaskV2, "The task"), 404: problemResponse("No task with that id"), 422: problemResponse("The id isn't a UUID") },
}),
async (c) => {
const { id } = c.req.valid("param");
const row = await findTask(id);
return row ? c.json(toTaskV2(row), 200) : missing(c, id);
},
);
const SUNSET = new Date("2027-06-30T00:00:00Z");
const DEPRECATED_AT = new Date("2026-10-01T00:00:00Z");
tasksApi.openapi(
createRoute({
method: "get", path: "/v0/tasks", tags: ["Tasks v0 (deprecated)"], deprecated: true,
summary: "A retired version, kept to demonstrate the Deprecation and Sunset headers",
responses: { 200: { ...json(z.object({ tasks: z.array(z.object({ id: z.uuid(), title: z.string(), done: z.boolean() })) }), "The old shape"), headers: z.object({ Deprecation: z.string(), Sunset: z.string(), Link: z.string() }) } },
}),
async (c) => {
const rows = await (await db()).select().from(tasks).orderBy(desc(tasks.createdAt), desc(tasks.id)).limit(20);
// RFC 9745: when it was deprecated. RFC 8594: when it stops answering.
// Link: where to go instead, and where the change is explained.
c.header("Deprecation", `@${Math.floor(DEPRECATED_AT.getTime() / 1000)}`);
c.header("Sunset", SUNSET.toUTCString());
c.header("Link", '</api/tasks>; rel="successor-version", </versioning>; rel="deprecation"');
return c.json({ tasks: rows.map((r) => ({ id: r.id, title: r.title, done: r.done })) }, 200);
},
);