Skip to content

Commit c8b08f1

Browse files
committed
Add OpenAPI contract generation and typed client
1 parent d7c33ab commit c8b08f1

26 files changed

Lines changed: 1317 additions & 32 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ test-results/
77
.env
88
.env.local
99
*.generated.*
10+
!src/generated/
11+
!src/generated/*.generated.*

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This is a TypeScript monorepo using pnpm workspaces. The application follows a d
1414
| Architecture & dependency rules | [docs/architecture.md](./docs/architecture.md) |
1515
| Testing procedure | [docs/testing.md](./docs/testing.md) |
1616
| React conventions | [docs/react.md](./docs/react.md) |
17+
| OpenAPI and typed client generation | [docs/openapi.md](./docs/openapi.md) |
1718
| Core beliefs & principles | [docs/beliefs.md](./docs/beliefs.md) |
1819
| Quality tracking | [docs/quality.md](./docs/quality.md) |
1920

@@ -38,6 +39,8 @@ pnpm · TypeScript · Fastify + React/Vite · TanStack Query · PostgreSQL + Dri
3839
| `pnpm start` | Start the local Docker Postgres, API, and web stack |
3940
| `pnpm preview` | Build the web app and run a pseudo-production stack |
4041
| `pnpm stop` | Stop the local stack and Docker resources |
42+
| `pnpm api:generate` | Regenerate the OpenAPI spec and typed frontend client |
43+
| `pnpm api:check` | Verify generated API artifacts are current |
4144
| `pnpm test:unit` | Run fast co-located unit tests |
4245
| `pnpm test:integration` | Start the stack and run database/runtime integration tests |
4346
| `pnpm test:e2e` | Start the stack and run browser e2e tests |

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pnpm start # Start Docker Compose Postgres, API, and web for this worktree
1212
pnpm health # Print health checks and the allocated URLs
1313
pnpm preview # Build and run a pseudo-production stack
1414
pnpm test:unit # Fast unit tests
15+
pnpm api:generate # Regenerate OpenAPI spec and typed frontend client
1516
pnpm test # Unit, integration, and e2e tests
1617
pnpm lint # Biome + architectural linting
1718
pnpm check:docs # Verify doc freshness
@@ -24,7 +25,7 @@ Use `pnpm logs -- --service api --lines 120` to inspect API logs. Use `pnpm seed
2425

2526
See [docs/architecture.md](./docs/architecture.md) for the full picture.
2627

27-
Each business domain follows a strict layered model. The React UI uses TanStack Query for server-state fetching, mutation, caching, and invalidation.
28+
Each business domain follows a strict layered model. The React UI uses TanStack Query for server-state fetching, mutation, caching, and invalidation. HTTP route contracts generate the OpenAPI spec and typed frontend client; see [docs/openapi.md](./docs/openapi.md).
2829

2930
```
3031
Types → Config → Repo → Service → Runtime → UI
@@ -47,7 +48,7 @@ When an agent needs the running app URL, use `pnpm health` or read `.stack/<work
4748
3. Update this README with the product name and local setup notes.
4849
4. Replace or rename the example domain under `src/domains/example/`.
4950
5. Add your first real domain by starting at the `types/` layer, then move forward through config, repo, service, runtime, and UI as needed.
50-
6. Keep [AGENTS.md](./AGENTS.md), [docs/implementation.md](./docs/implementation.md), [docs/testing.md](./docs/testing.md), and [docs/react.md](./docs/react.md) current as the project develops.
51+
6. Keep [AGENTS.md](./AGENTS.md), [docs/implementation.md](./docs/implementation.md), [docs/testing.md](./docs/testing.md), [docs/openapi.md](./docs/openapi.md), and [docs/react.md](./docs/react.md) current as the project develops.
5152
7. Run `pnpm lint`, `pnpm test`, `pnpm build`, and `pnpm check:docs` before treating the template migration as complete.
5253

5354
## For Agents

docs/architecture.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Every business domain is organized into six layers with **strict forward-only de
66

77
```
88
Types → Config → Repo → Service → Runtime → UI
9+
│ │ ▲
10+
└── Zod request/response schemas ─────┘ │
11+
│ │
12+
▼ │
13+
OpenAPI spec + typed API client ───┘
914
```
1015

1116
### Layer Responsibilities
@@ -16,8 +21,10 @@ Types → Config → Repo → Service → Runtime → UI
1621
| **config/** | Domain configuration, defaults, env parsing | types |
1722
| **repo/** | Data access, database queries, external API clients | types, config |
1823
| **service/** | Business logic, orchestration, domain rules | types, config, repo |
19-
| **runtime/** | Server routes, background jobs, event handlers | types, config, repo, service |
20-
| **ui/** | React components, hooks, pages | types, config (client-safe only) |
24+
| **runtime/** | Server routes, route contracts, background jobs, event handlers | types, config, repo, service |
25+
| **ui/** | React components, hooks, pages; uses generated API client for HTTP | types, config (client-safe only), generated client |
26+
27+
Route contracts live in `runtime/contract.ts` because they describe the HTTP boundary. They depend on lower-layer Zod schemas and provider contract types, then feed generated artifacts under `src/generated/`. UI code may import the generated client and exported client-safe types, but it must not import runtime route modules directly.
2128

2229
### Cross-Cutting Concerns (Providers)
2330

@@ -26,6 +33,7 @@ Database, telemetry, auth, feature flags, and shared connectors (cache, queue) l
2633
```
2734
src/providers/
2835
├── database/ # Postgres client and lifecycle
36+
├── openapi/ # Route contract to OpenAPI document builder
2937
├── telemetry/ # Structured Pino logging; metrics/traces are future work
3038
├── auth/ # Authentication & authorization
3139
└── feature-flags/ # Feature flag evaluation
@@ -39,17 +47,20 @@ These rules are enforced by the custom linter at `lints/check-deps.ts`:
3947
2. **No cross-domain imports at lower layers.** `domainA/repo` cannot import `domainB/repo`. Cross-domain communication happens at the `service` layer or above.
4048
3. **No direct cross-cutting imports.** Use `src/providers/`, not raw `pino` or `@opentelemetry/*` imports in domain code.
4149
4. **UI only imports types and client-safe config.** No server-side code in the UI layer.
42-
5. **Co-located tests are required.** Source modules must have adjacent unit or integration tests unless they are approved entrypoints or barrel files.
43-
6. **Structured logging only.** Application code must not use `console.*`; use providers so stack logs stay queryable.
50+
5. **Generated API client is the UI HTTP boundary.** Browser code should call `src/generated/api-client.generated.ts`, not hand-written `fetch` wrappers for app routes.
51+
6. **Co-located tests are required.** Source modules must have adjacent unit or integration tests unless they are approved entrypoints, generated files, or barrel files.
52+
7. **Structured logging only.** Application code must not use `console.*`; use providers so stack logs stay queryable.
4453

4554
### Adding a New Domain
4655

4756
1. Create `src/domains/<name>/` with all six layer directories
4857
2. Add types and Zod schemas first (types layer is the foundation)
49-
3. Register routes in the runtime layer
50-
4. Add co-located tests for every source module
51-
5. Add browser e2e coverage when the domain exposes user-visible flows
52-
6. Update [implementation.md](./implementation.md), [testing.md](./testing.md), or domain-specific docs when behavior changes
58+
3. Add route contracts and route handlers in the runtime layer
59+
4. Register domain contracts in `src/api-contracts.ts`
60+
5. Run `pnpm api:generate` when HTTP behavior changes
61+
6. Add co-located tests for every source module
62+
7. Add browser e2e coverage when the domain exposes user-visible flows
63+
8. Update [implementation.md](./implementation.md), [testing.md](./testing.md), [openapi.md](./openapi.md), or domain-specific docs when behavior changes
5364

5465
### File Conventions
5566

docs/implementation.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Use this process when adding or changing application behavior. Keep changes smal
1010
- Check [quality.md](./quality.md) for known gaps in the area you are touching.
1111
- Check [testing.md](./testing.md) before choosing test coverage.
1212
- Check [react.md](./react.md) before changing UI components or hooks.
13+
- Check [openapi.md](./openapi.md) before changing HTTP routes, API payloads, or generated client usage.
1314
- Prefer existing domain patterns over new abstractions.
1415

1516
## 2. Design The Change By Layer
@@ -26,19 +27,34 @@ For a new feature, usually work in this order:
2627
2. `config/`: add defaults or environment parsing when behavior needs configuration.
2728
3. `repo/`: add database or external data access and parse returned rows before leaving the layer.
2829
4. `service/`: add business rules and orchestration. Keep this testable with injected dependencies.
29-
5. `runtime/`: add routes, handlers, jobs, or adapters. Parse request input at the boundary.
30-
6. `ui/`: add React components and hooks for browser-visible behavior. Use `useState` for local interaction state and TanStack Query for server state. Do not use `useEffect`.
30+
5. `runtime/`: add route contracts, routes, handlers, jobs, or adapters. Parse request input at the boundary.
31+
6. `ui/`: add React components and hooks for browser-visible behavior. Use `useState` for local interaction state and TanStack Query for server state. Use the generated API client for HTTP calls. Do not use `useEffect`.
3132

3233
Skip layers that do not apply. Do not bypass lower layers just to make the change faster.
3334

34-
## 3. Write Tests With The Pyramid
35+
## 3. Keep API Contracts Generated
36+
37+
When a feature adds or changes HTTP behavior:
38+
39+
1. Define request, response, and path parameter schemas in the domain `types/` layer. Response schemas must describe JSON payloads, not internal service objects.
40+
2. Add or update the domain route contract in `runtime/contract.ts`, including `method`, `operationId`, `path`, `responses`, and `client` metadata for browser-callable routes.
41+
3. Register the contract from `src/api-contracts.ts`.
42+
4. Run `pnpm api:generate` to refresh `src/generated/openapi.generated.json` and `src/generated/api-client.generated.ts`.
43+
5. Use the generated client from UI code instead of hand-written `fetch` calls.
44+
45+
Use [openapi.md](./openapi.md) for the exact contract shape, client metadata fields, and verification checklist.
46+
47+
Generated API artifacts are committed source artifacts. `pnpm build` runs `pnpm api:check`, so stale OpenAPI/client output should fail validation before merge.
48+
49+
## 4. Write Tests With The Pyramid
3550

3651
- Add or update co-located unit tests for most logic.
3752
- Add integration tests when the behavior depends on Postgres, route wiring, provider behavior, migrations, or another real boundary.
3853
- Add e2e tests for critical browser journeys and visible failure states.
54+
- Add contract/generator tests when route metadata, generated OpenAPI output, or generated client behavior changes.
3955
- Avoid duplicating the same assertion at every layer. Unit tests should cover combinations; e2e tests should prove the journey works.
4056

41-
## 4. Validate
57+
## 5. Validate
4258

4359
For source-only changes:
4460

@@ -52,16 +68,18 @@ For API, database, UI, or browser-visible changes:
5268

5369
```bash
5470
pnpm lint
71+
pnpm api:check
5572
pnpm test
5673
pnpm check:docs
5774
```
5875

5976
Use `pnpm start`, `pnpm seed`, `pnpm health`, `pnpm logs`, and `pnpm stop` when you need to inspect the running stack manually. Use `pnpm preview` for a built pseudo-production smoke check.
6077

61-
## 5. Update Documentation
78+
## 6. Update Documentation
6279

6380
- Update [testing.md](./testing.md) when commands or test expectations change.
6481
- Update [react.md](./react.md) when UI patterns or component rules change.
82+
- Update [openapi.md](./openapi.md) when API contract generation or generated client conventions change.
6583
- Update [quality.md](./quality.md) when you improve coverage or identify a durable gap.
6684
- Update [architecture.md](./architecture.md) only when the layer model or dependency rules change.
6785
- Add a focused design note only for decisions that future agents must understand to modify the feature safely.

docs/openapi.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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`.

docs/quality.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Track the health of each domain and architectural layer. Update this when you im
2323
| auth | D | Placeholder |
2424
| database | B | Postgres provider wired through Docker Compose stack |
2525
| telemetry | B | Pino logger, request IDs, route timings, and stack log files are wired; metrics/traces are future work |
26+
| openapi | B | Route contracts generate `openapi.generated.json` and a typed frontend client; broader coverage should grow as domains are added |
2627
| feature-flags | D | Placeholder |
2728

2829
## Known Gaps

0 commit comments

Comments
 (0)