|
| 1 | +# OpenAPI And Typed Client |
| 2 | + |
| 3 | +Last verified: 2026-05-05 |
| 4 | + |
| 5 | +The HTTP contract is generated from TypeScript route contract metadata and Zod schemas. Domain runtime layers own their route contracts because routes are the HTTP boundary, and the generated frontend client imports only client-safe domain types. |
| 6 | + |
| 7 | +## Files |
| 8 | + |
| 9 | +| What | Where | |
| 10 | +|------|-------| |
| 11 | +| OpenAPI document builder | `src/providers/openapi/` | |
| 12 | +| App route contract registry | `src/api-contracts.ts` | |
| 13 | +| Example domain route contracts | `src/domains/example/runtime/contract.ts` | |
| 14 | +| Generated OpenAPI spec | `src/generated/openapi.generated.json` | |
| 15 | +| Generated frontend client | `src/generated/api-client.generated.ts` | |
| 16 | + |
| 17 | +## Commands |
| 18 | + |
| 19 | +| Command | Purpose | |
| 20 | +|---------|---------| |
| 21 | +| `pnpm api:generate` | Regenerate the OpenAPI JSON document and frontend client | |
| 22 | +| `pnpm api:check` | Fail when generated API artifacts are stale | |
| 23 | + |
| 24 | +`pnpm build` runs `pnpm api:check` before TypeScript and Vite so stale generated API artifacts fail CI. |
| 25 | + |
| 26 | +## Adding Or Changing Routes |
| 27 | + |
| 28 | +1. Add or update request, response, and parameter schemas in the domain `types/` layer. Use JSON-serializable response schemas for HTTP payloads; for example, date-time fields should be strings in response schemas even if service-layer domain entities use `Date`. |
| 29 | +2. Add or update the route contract in the domain `runtime/contract.ts`. |
| 30 | +3. Implement the Fastify route in the domain `runtime/routes.ts`. |
| 31 | +4. Run `pnpm api:generate`. |
| 32 | +5. Use `src/generated/api-client.generated.ts` from UI code instead of hand-written `fetch` calls. |
| 33 | + |
| 34 | +## Contract Shape |
| 35 | + |
| 36 | +Each domain that exposes HTTP routes should have a `runtime/contract.ts` file that exports one readonly array named after the domain, such as `itemRouteContracts`. The array must satisfy `readonly ApiRouteContract[]`. |
| 37 | + |
| 38 | +```ts |
| 39 | +import type { ApiRouteContract } from "@providers/openapi/index.js"; |
| 40 | +import { z } from "zod"; |
| 41 | +import { |
| 42 | + CreateThingSchema, |
| 43 | + ThingIdSchema, |
| 44 | + ThingResponseSchema, |
| 45 | +} from "../types/index.js"; |
| 46 | + |
| 47 | +const ErrorResponseSchema = z.object({ |
| 48 | + error: z.string(), |
| 49 | +}); |
| 50 | + |
| 51 | +const ThingParamsSchema = z.object({ |
| 52 | + id: ThingIdSchema, |
| 53 | +}); |
| 54 | + |
| 55 | +const thingTypeImports = [ |
| 56 | + { |
| 57 | + kind: "type", |
| 58 | + module: "../domains/example/types/index.js", |
| 59 | + names: ["CreateThing", "ThingResponse"], |
| 60 | + }, |
| 61 | +] as const; |
| 62 | + |
| 63 | +const thingResponseSchemaImports = [ |
| 64 | + { |
| 65 | + kind: "value", |
| 66 | + module: "../domains/example/types/index.js", |
| 67 | + names: ["ThingResponseSchema"], |
| 68 | + }, |
| 69 | +] as const; |
| 70 | + |
| 71 | +export const thingRouteContracts = [ |
| 72 | + { |
| 73 | + method: "post", |
| 74 | + operationId: "createThing", |
| 75 | + path: "/api/things", |
| 76 | + requestBody: CreateThingSchema, |
| 77 | + responses: { |
| 78 | + 201: { description: "Created thing", schema: ThingResponseSchema }, |
| 79 | + 400: { description: "Invalid thing data", schema: ErrorResponseSchema }, |
| 80 | + }, |
| 81 | + summary: "Create thing", |
| 82 | + tags: ["things"], |
| 83 | + client: { |
| 84 | + functionName: "createThing", |
| 85 | + imports: [...thingTypeImports, ...thingResponseSchemaImports], |
| 86 | + requestBodyType: "CreateThing", |
| 87 | + responseParser: "ThingResponseSchema", |
| 88 | + responseType: "ThingResponse", |
| 89 | + }, |
| 90 | + }, |
| 91 | +] as const satisfies readonly ApiRouteContract[]; |
| 92 | +``` |
| 93 | + |
| 94 | +## Contract Fields |
| 95 | + |
| 96 | +| Field | Required | Purpose | |
| 97 | +|-------|----------|---------| |
| 98 | +| `method` | Yes | Lowercase HTTP method: `get`, `post`, `put`, `patch`, or `delete`. | |
| 99 | +| `operationId` | Yes | Stable OpenAPI operation name. Use a verb phrase like `createItem`; do not reuse within the app. | |
| 100 | +| `path` | Yes | Fastify-style route path. Use `:id` path parameters; the OpenAPI builder converts them to `{id}`. | |
| 101 | +| `pathParams` | When the path has params | Zod object for path params. Keys must match every `:param` segment in `path`. | |
| 102 | +| `requestBody` | For JSON body routes | Zod schema for the request JSON body. Omit for bodyless routes. | |
| 103 | +| `responses` | Yes | Map of HTTP status codes to response descriptions and optional Zod response schemas. Omit `schema` for empty responses like `204`. | |
| 104 | +| `summary` | Yes | Short human-readable OpenAPI summary. | |
| 105 | +| `tags` | Recommended | OpenAPI grouping tags, usually the domain or resource name. | |
| 106 | +| `client` | For browser-callable routes | Metadata used to generate the frontend client function. Omit only for routes that should not be called from browser UI. | |
| 107 | + |
| 108 | +## Client Metadata |
| 109 | + |
| 110 | +`client` controls the generated function in `src/generated/api-client.generated.ts`. |
| 111 | + |
| 112 | +| Field | Required | Purpose | |
| 113 | +|-------|----------|---------| |
| 114 | +| `functionName` | Yes | Method name on `apiClient`, such as `apiClient.createItem`. | |
| 115 | +| `imports` | When client types or parsers are referenced | Type/value imports emitted into the generated client. Paths are relative to `src/generated/api-client.generated.ts`. | |
| 116 | +| `pathParamsType` | When the path has params | TypeScript type for the generated `params` argument, usually `{ id: string }`. | |
| 117 | +| `requestBodyType` | When `requestBody` exists | TypeScript type for the generated `body` argument. | |
| 118 | +| `responseType` | Yes | Promise result type for the generated client method. Use `void` for `204`-only success responses. | |
| 119 | +| `responseParser` | For JSON responses | Zod parser expression used by the generated client at the browser boundary, such as `ItemResponseSchema` or `ItemResponseSchema.array()`. | |
| 120 | + |
| 121 | +Use `kind: "type"` imports for TypeScript-only names and `kind: "value"` imports for Zod schemas referenced by `responseParser`. |
| 122 | + |
| 123 | +```ts |
| 124 | +client: { |
| 125 | + functionName: "listItems", |
| 126 | + imports: [...itemTypeImports, ...itemResponseSchemaImports], |
| 127 | + responseParser: "ItemResponseSchema.array()", |
| 128 | + responseType: "ItemResponse[]", |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +For a `DELETE` route that returns `204`, omit `responseParser` and use `responseType: "void"`. |
| 133 | + |
| 134 | +## Schema Rules |
| 135 | + |
| 136 | +- Request schemas should describe the raw JSON sent by clients. |
| 137 | +- Response schemas should describe the JSON returned over HTTP, not necessarily the service-layer domain entity. If the service uses `Date`, the response schema should usually use `z.iso.datetime()` because JSON carries strings. |
| 138 | +- Error responses should use explicit schemas, commonly `z.object({ error: z.string() })`, so OpenAPI documents failure shapes too. |
| 139 | +- Path parameter schemas should reuse domain value schemas such as `ItemIdSchema`. |
| 140 | +- Do not import `repo`, `service`, or `ui` code from `runtime/contract.ts`; contracts should depend on `types` and provider contract types only. |
| 141 | + |
| 142 | +## Registration |
| 143 | + |
| 144 | +After adding a domain contract, register it in `src/api-contracts.ts`. |
| 145 | + |
| 146 | +```ts |
| 147 | +import { itemRouteContracts } from "./domains/example/runtime/contract.js"; |
| 148 | +import { thingRouteContracts } from "./domains/thing/runtime/contract.js"; |
| 149 | + |
| 150 | +export const apiRouteContracts = [...itemRouteContracts, ...thingRouteContracts] as const; |
| 151 | +``` |
| 152 | + |
| 153 | +The server and generator both consume `apiRouteContracts`, so this is the single app-level registry for OpenAPI and client generation. |
| 154 | + |
| 155 | +## Route Implementation |
| 156 | + |
| 157 | +The contract does not register Fastify handlers. Keep handler implementation in the domain `runtime/routes.ts`, and make sure the actual route behavior matches the contract: |
| 158 | + |
| 159 | +- same `method` and `path` |
| 160 | +- same success status codes |
| 161 | +- same request body validation |
| 162 | +- same path parameter validation |
| 163 | +- same error response shape |
| 164 | + |
| 165 | +`/openapi.json` is served by `src/app-server.ts` from the registered contracts. |
| 166 | + |
| 167 | +## Verification Checklist |
| 168 | + |
| 169 | +When changing contracts, verify all of the following: |
| 170 | + |
| 171 | +1. `runtime/contract.ts` has co-located tests for operation IDs, client function names, and any serialization-sensitive schemas. |
| 172 | +2. `runtime/routes.ts` has tests or integration coverage for boundary validation and status codes. |
| 173 | +3. `pnpm api:generate` updates both generated files. |
| 174 | +4. `pnpm api:check` passes without rewriting artifacts. |
| 175 | +5. UI code imports `apiClient` or generated exported types from `src/generated/api-client.generated.ts`. |
0 commit comments