OpenAPI docs

One description of the API, generated from the code that runs it.

What it is

OpenAPI is a standard, machine-readable description of an HTTP API: its paths, methods, parameters, bodies and responses. Here it isn't written by hand: @hono/zod-openapi builds it from the same Zod schemas and route definitions that validate requests, so a field can't be added to the code and forgotten in the docs. The document is served at /api/openapi.json; tools can generate typed clients, mock servers and tests from it, and this page renders it as reference docs.

When to use it

  • Any API with consumers outside your own codebase
  • Generating typed clients (openapi-typescript, Orval) instead of hand-writing them
  • Contract tests and mock servers from one source of truth
Official docs ↗

API Feature Lab 1.0.0

get/api/tasksList tasks, a page at a time
Parameters
  • done (query): string. Only finished (true) or open (false) tasks
  • limit (query): integer. Page size, 1–50
  • cursor (query): string. nextCursor from the previous page
  • offset (query): integer,null. Offset pagination, for comparison: skips rows
Responses
  • 200 The newest tasks first. Link: <…>; rel="next" when there's more: TaskPage as application/json
  • 304 Not modified: the page is unchanged since the ETag you sent
  • 422 Invalid query or cursor: Problem as application/problem+json
post/api/tasksCreate a task
Body: NewTask as application/json
Responses
  • 201 Created. The Location header holds the new task's URL: Task as application/json
  • 400 The body isn't JSON: Problem as application/problem+json
  • 422 The JSON doesn't match NewTask: Problem as application/problem+json
get/api/tasks/{id}Get one task (API-Version: 1 or 2)
Parameters
  • id (path, required): string
  • api-version (header): string. 1 (default) or 2: which representation to return
Responses
  • 200 The task, as Task (version 1) or TaskV2: as application/json
  • 304 Not modified: your copy (If-None-Match) is current
  • 400 Unknown API-Version: Problem as application/problem+json
  • 404 No task with that id: Problem as application/problem+json
  • 422 The id isn't a UUID: Problem as application/problem+json
patch/api/tasks/{id}Change some fields of a task (If-Match for safe concurrent edits)
Parameters
  • id (path, required): string
  • if-match (header): string. The ETag you last saw. 412 if the task has changed since
Body: TaskPatch as application/json
Responses
  • 200 The updated task, with its new ETag: Task as application/json
  • 404 No task with that id: Problem as application/problem+json
  • 412 If-Match didn't match: someone changed the task first: Problem as application/problem+json
  • 422 Invalid fields: Problem as application/problem+json
delete/api/tasks/{id}Delete a task
Parameters
  • id (path, required): string
  • if-match (header): string
Responses
  • 204 Deleted. No body
  • 404 No task with that id: Problem as application/problem+json
  • 412 If-Match didn't match: Problem as application/problem+json
get/api/v2/tasksList tasks in the version 2 shape
Parameters
  • done (query): string. Only finished (true) or open (false) tasks
  • limit (query): integer. Page size, 1–50
  • cursor (query): string. nextCursor from the previous page
  • offset (query): integer,null. Offset pagination, for comparison: skips rows
Responses
  • 200 Like /api/tasks, as TaskV2: TaskPageV2 as application/json
  • 422 Invalid query or cursor: Problem as application/problem+json
get/api/v2/tasks/{id}Get one task in the version 2 shape
Parameters
  • id (path, required): string
Responses
  • 200 The task: TaskV2 as application/json
  • 404 No task with that id: Problem as application/problem+json
  • 422 The id isn't a UUID: Problem as application/problem+json
get/api/v0/tasksA retired version, kept to demonstrate the Deprecation and Sunset headers
Responses
  • 200 The old shape: object as application/json
post/api/keysCreate an API key (shown once)
Body: NewKey as application/json
Responses
  • 201 The new key. Store it now: it can't be shown again: CreatedKey as application/json
  • 422 Invalid name or scope: Problem as application/problem+json
  • 429 Too many keys created: Problem as application/problem+json
delete/api/keys/{id}Revoke a key (authenticate with that same key)
Parameters
  • id (path, required): string
Responses
  • 204 Revoked: the key stops working at once
  • 401 No or bad credentials: Problem as application/problem+json
  • 403 Not your key, or not an API key: Problem as application/problem+json
post/api/tokenExchange an API key for a 15-minute JWT
Responses
  • 200 A signed, short-lived access token (OAuth 2 style response): Token as application/json
  • 401 No or bad API key: Problem as application/problem+json
  • 403 Sent a token, not a key: Problem as application/problem+json
  • 503 Tokens aren't configured: Problem as application/problem+json
get/api/meWho am I? (any valid key or token)
Responses
  • 200 The caller's identity and scopes: Identity as application/json
  • 401 No or bad credentials: Problem as application/problem+json
get/api/statsTask and key counts (needs stats:read)
Responses
  • 200 The numbers: object as application/json
  • 401 No or bad credentials: Problem as application/problem+json
  • 403 Missing the stats:read scope: Problem as application/problem+json
get/api/export/tasksAll tasks as CSV (needs tasks:export)
Responses
  • 200 One row per task: string as text/csv
  • 401 No or bad credentials: Problem as application/problem+json
  • 403 Missing the tasks:export scope: Problem as application/problem+json
get/api/limits/demoA cheap endpoint limited to 5 requests per 10s
Responses
  • 200 Allowed. See the RateLimit headers: object as application/json
  • 429 Over the limit. Retry-After says when to try again: Problem as application/problem+json

Schemas

TaskPage
{
  "type": "object",
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/Task"
      }
    },
    "nextCursor": {
      "type": [
        "string",
        "null"
      ],
      "description": "Pass as ?cursor= for the next page; null on the last page"
    },
    "nextOffset": {
      "type": "integer",
      "description": "Only in offset mode"
    }
  },
  "required": [
    "items",
    "nextCursor"
  ]
}
Task
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "title": {
      "type": "string"
    },
    "done": {
      "type": "boolean"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  },
  "required": [
    "id",
    "title",
    "done",
    "createdAt",
    "updatedAt"
  ]
}
Problem
{
  "type": "object",
  "properties": {
    "type": {
      "type": "string",
      "example": "https://api.djmiskey.com/problems/validation"
    },
    "title": {
      "type": "string",
      "example": "Your request isn't valid"
    },
    "status": {
      "type": "integer",
      "example": 422
    },
    "detail": {
      "type": "string"
    },
    "instance": {
      "type": "string",
      "example": "/api/tasks"
    },
    "errors": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "path",
          "message"
        ]
      }
    }
  },
  "required": [
    "type",
    "title",
    "status"
  ]
}
NewTask
{
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "done": {
      "type": "boolean"
    }
  },
  "required": [
    "title"
  ],
  "additionalProperties": false,
  "example": {
    "title": "Learn about status codes"
  }
}
TaskV2
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "title": {
      "type": "string"
    },
    "status": {
      "type": "string",
      "enum": [
        "open",
        "done"
      ]
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    },
    "links": {
      "type": "object",
      "properties": {
        "self": {
          "type": "string"
        }
      },
      "required": [
        "self"
      ]
    }
  },
  "required": [
    "id",
    "title",
    "status",
    "createdAt",
    "updatedAt",
    "links"
  ]
}
TaskPatch
{
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "done": {
      "type": "boolean"
    }
  },
  "additionalProperties": false,
  "example": {
    "done": true
  }
}
TaskPageV2
{
  "type": "object",
  "properties": {
    "items": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/TaskV2"
      }
    },
    "nextCursor": {
      "type": [
        "string",
        "null"
      ],
      "description": "Pass as ?cursor= for the next page; null on the last page"
    },
    "nextOffset": {
      "type": "integer",
      "description": "Only in offset mode"
    }
  },
  "required": [
    "items",
    "nextCursor"
  ]
}
CreatedKey
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "name": {
      "type": "string"
    },
    "prefix": {
      "type": "string"
    },
    "scopes": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "stats:read",
          "tasks:export"
        ]
      }
    },
    "expiresAt": {
      "type": "string",
      "format": "date-time"
    },
    "key": {
      "type": "string",
      "description": "The key itself. Shown only now: only its hash is stored"
    }
  },
  "required": [
    "id",
    "name",
    "prefix",
    "scopes",
    "expiresAt",
    "key"
  ]
}
NewKey
{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 60
    },
    "scopes": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "stats:read",
          "tasks:export"
        ]
      },
      "maxItems": 2,
      "default": []
    }
  },
  "required": [
    "name"
  ],
  "additionalProperties": false,
  "example": {
    "name": "My script",
    "scopes": [
      "stats:read"
    ]
  }
}
Token
{
  "type": "object",
  "properties": {
    "access_token": {
      "type": "string"
    },
    "token_type": {
      "type": "string",
      "enum": [
        "Bearer"
      ]
    },
    "expires_in": {
      "type": "integer"
    },
    "scope": {
      "type": "string"
    }
  },
  "required": [
    "access_token",
    "token_type",
    "expires_in",
    "scope"
  ]
}
Identity
{
  "type": "object",
  "properties": {
    "method": {
      "type": "string",
      "enum": [
        "api-key",
        "jwt"
      ]
    },
    "keyId": {
      "type": "string",
      "format": "uuid"
    },
    "name": {
      "type": "string"
    },
    "scopes": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "stats:read",
          "tasks:export"
        ]
      }
    },
    "expiresAt": {
      "type": "string",
      "format": "date-time"
    }
  },
  "required": [
    "method",
    "keyId",
    "name",
    "scopes",
    "expiresAt"
  ]
}
Show code · src/api/app.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { HTTPException } from "hono/http-exception";
import { type AuthEnv, authenticate } from "./auth";
import { problem } from "./problem";
import { rateLimit } from "./ratelimit";
import { authApi } from "./routes/auth";
import { limitsApi } from "./routes/limits";
import { tasksApi } from "./routes/tasks";
import { validationHook } from "./validation";

// The whole API: one Hono app under /api, mounted into Next.js by
// src/app/api/[[...route]]/route.ts. Hono only sees standard Request and
// Response objects, so the same app could run on Workers, Deno or Bun.
export const app = new OpenAPIHono<AuthEnv>({ defaultHook: validationHook }).basePath("/api");

// Every request: check credentials if any were sent (see auth.ts).
app.use("*", authenticate);

// Writes to the public tasks are rate limited per client: 30 a minute
// anonymously, 120 with an API key or token. (LAB_WRITE_LIMIT raises the
// anonymous limit for the test suites.)
// ("/tasks/*" also matches "/tasks" itself: listing both would count every
// write twice.)
app.on(["POST", "PATCH", "DELETE"], "/tasks/*", rateLimit({
  name: "writes",
  windowSec: 60,
  limit: (c) => (c.get("auth") ? 120 : Number(process.env.LAB_WRITE_LIMIT ?? 30)),
}));

app.get("/health", (c) => c.json({ ok: true }));
app.route("/", tasksApi);
app.route("/", authApi);
app.route("/", limitsApi);

// The OpenAPI document is generated from the same Zod schemas that validate
// requests, so the docs can't drift from the code.
app.doc31("/openapi.json", {
  openapi: "3.1.0",
  info: {
    title: "API Feature Lab",
    version: "1.0.0",
    description: "The API behind https://api.djmiskey.com: one page per technique, each calling these endpoints live.",
  },
  servers: [{ url: "https://api.djmiskey.com" }],
});

// Every failure leaves as Problem Details, even ones Hono raises itself.
app.notFound((c) => problem(c, 404, "not-found", { detail: `No route for ${c.req.method} ${new URL(c.req.url).pathname}.` }));
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    // Hono's JSON validator throws 400 for a body that isn't valid JSON.
    return problem(c, err.status === 400 ? 400 : 500, err.status === 400 ? "bad-request" : "internal", { detail: err.message });
  }
  console.error(err);
  return problem(c, 500, "internal");
});
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);
  },
);