openapi contract

why

separate route code from the OpenAPI document and they will disagree: a field added to the route but not the spec, a client generated from stale docs, a runtime mismatch between expected and actual response. exporting OpenAPI from the live route keeps the spec current — one place to change, every consumer regenerates from that single fact.

rule

the live route graph is the only source of truth for HTTP behavior and OpenAPI. named Zod schemas define request, success, and diagnostic shapes; the runtime route mounts those schemas; OpenAPI is exported from that same mounted graph. a handwritten document, duplicate route list, or client-only DTO is drift by construction.

contract chain

interface outcome
-> named Zod schemas with OpenAPI identities
-> live Hono/OpenAPI route declaration
-> generated OpenAPI document
-> Fern client
-> consuming hook and interface

each operation needs a precise, globally navigable ID. request schemas make the transport boundary explicit; response schemas describe both the success projection and the failures that alter a consumer's next action.

case study: process operations

the route graph mounts named process topology and active-status operations. the topology operation declares path/query schemas, a success schema, and typed not-found, conflict, and dependency-failure diagnostics; the status operation declares the narrower projection its panel consumes. both handlers validate inputs, call the domain service, and envelope the result.

the route graph is executable API behavior and code-generation input at once — Fern needs no second description an engineer could forget to update.

change and verification sequence

  1. start with the consuming interface outcome and name the operation.
  2. define shared Zod request, success, and error schemas with OpenAPI names.
  3. mount them on the live route; validate/authenticate at that boundary.
  4. generate OpenAPI from the route graph; inspect the changed operation and schemas rather than hand-editing the artifact.
  5. regenerate Fern and update every typed consumer.
  6. typecheck and smoke the route with representative success and failure input.

do not

  • maintain a second route inventory solely for OpenAPI generation.
  • hand-edit exported OpenAPI or generated clients.
  • give the same operation multiple names across route, SDK, hook, and docs.
  • declare generic success while leaving consumers to parse error strings.
  • put provider, database, or UI rendering details in the route contract when they do not represent an interface fact.

good and bad

bad

# openapi.yaml (handwritten, separate from routes)
paths:
/workflow-runs/{id}:
get:
operationId: getWorkflowRun
responses:
200:
schema:
properties:
dueAt: { type: string } # spec says dueAt
// route code (evolving separately from spec)
app.get("/workflow-runs/:id", async (context) => {
return context.json({ id, dueDate }); // code returns dueDate
});
// generated Fern client (built from stale spec)
export async function getWorkflowRun(id: string) {
const response = await fetch(`/workflow-runs/${id}`);
return response.json() as { dueAt: string };
}
// consuming interface
const run = await getWorkflowRun(runId);
console.log(run.dueAt); // undefined — spec and code disagree
const date = new Date(run.dueAt); // NaN — crash at runtime

the spec describes a field the code doesn't return; the generated client trusts the spec and crashes when the field is undefined.

good

// route schema (authored source of truth)
export const GetWorkflowRunResponseSchema = z.object({
id: z.string(),
dueDate: z.string(),
});
// route mounts schema with OpenAPI identity
app.openapi(GetWorkflowRunRoute, async (context) => {
const id = context.req.param("id");
return context.json(await getWorkflowRun(id));
});
// export OpenAPI from live route graph
const openapi = app.getOpenAPI3();
// generated Fern client (built from live routes)
export async function getWorkflowRun(id: string) {
const response = await fetch(`/workflow-runs/${id}`);
return response.json() as { dueDate: string };
}
// consuming interface
const run = await getWorkflowRun(runId);
console.log(run.dueDate); // ✓ field exists and is correctly typed
const date = new Date(run.dueDate); // works

route changes update the exported spec automatically; every generated client sees the actual contract.

search pages

go to any page