Skip to content

Commit 6df036b

Browse files
committed
Generate TanStack Query API helpers
1 parent c8b08f1 commit 6df036b

7 files changed

Lines changed: 191 additions & 38 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Use `pnpm logs -- --service api --lines 120` to inspect API logs. Use `pnpm seed
2525

2626
See [docs/architecture.md](./docs/architecture.md) for the full picture.
2727

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).
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, typed frontend client, and TanStack Query helper factories; see [docs/openapi.md](./docs/openapi.md).
2929

3030
```
3131
Types → Config → Repo → Service → Runtime → UI

docs/architecture.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Types → Config → Repo → Service → Runtime → UI
1010
└── Zod request/response schemas ─────┘ │
1111
│ │
1212
▼ │
13-
OpenAPI spec + typed API client ───┘
13+
OpenAPI spec + typed API client
14+
+ TanStack Query helpers ───────────┘
1415
```
1516

1617
### Layer Responsibilities
@@ -22,9 +23,9 @@ Types → Config → Repo → Service → Runtime → UI
2223
| **repo/** | Data access, database queries, external API clients | types, config |
2324
| **service/** | Business logic, orchestration, domain rules | types, config, repo |
2425
| **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+
| **ui/** | React components, hooks, pages; uses generated API client and TanStack Query helpers for HTTP | types, config (client-safe only), generated client |
2627

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.
28+
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, generated TanStack Query option factories, generated query keys, and exported client-safe types, but it must not import runtime route modules directly.
2829

2930
### Cross-Cutting Concerns (Providers)
3031

@@ -47,7 +48,7 @@ These rules are enforced by the custom linter at `lints/check-deps.ts`:
4748
2. **No cross-domain imports at lower layers.** `domainA/repo` cannot import `domainB/repo`. Cross-domain communication happens at the `service` layer or above.
4849
3. **No direct cross-cutting imports.** Use `src/providers/`, not raw `pino` or `@opentelemetry/*` imports in domain code.
4950
4. **UI only imports types and client-safe config.** No server-side code in the UI layer.
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+
5. **Generated API client is the UI HTTP boundary.** Browser code should use `src/generated/api-client.generated.ts`, preferably through generated TanStack Query helpers, not hand-written `fetch` wrappers for app routes.
5152
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.
5253
7. **Structured logging only.** Application code must not use `console.*`; use providers so stack logs stay queryable.
5354

docs/implementation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ When a feature adds or changes HTTP behavior:
4040
2. Add or update the domain route contract in `runtime/contract.ts`, including `method`, `operationId`, `path`, `responses`, and `client` metadata for browser-callable routes.
4141
3. Register the contract from `src/api-contracts.ts`.
4242
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.
43+
5. Use the generated TanStack Query helpers from UI code instead of hand-written `fetch`, `queryKey`, `queryFn`, or `mutationFn` wrappers.
4444

4545
Use [openapi.md](./openapi.md) for the exact contract shape, client metadata fields, and verification checklist.
4646

docs/openapi.md

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Last verified: 2026-05-05
44

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.
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. The generated client also exposes TanStack Query option factories so UI code can share query keys, query functions, mutation keys, and mutation functions without hand-written wrappers.
66

77
## Files
88

@@ -12,7 +12,7 @@ The HTTP contract is generated from TypeScript route contract metadata and Zod s
1212
| App route contract registry | `src/api-contracts.ts` |
1313
| Example domain route contracts | `src/domains/example/runtime/contract.ts` |
1414
| Generated OpenAPI spec | `src/generated/openapi.generated.json` |
15-
| Generated frontend client | `src/generated/api-client.generated.ts` |
15+
| Generated frontend client and TanStack Query helpers | `src/generated/api-client.generated.ts` |
1616

1717
## Commands
1818

@@ -29,7 +29,7 @@ The HTTP contract is generated from TypeScript route contract metadata and Zod s
2929
2. Add or update the route contract in the domain `runtime/contract.ts`.
3030
3. Implement the Fastify route in the domain `runtime/routes.ts`.
3131
4. Run `pnpm api:generate`.
32-
5. Use `src/generated/api-client.generated.ts` from UI code instead of hand-written `fetch` calls.
32+
5. Use `apiQueries`, `apiMutations`, and `apiQueryKeys` from `src/generated/api-client.generated.ts` in UI code instead of hand-written `fetch`, `queryKey`, `queryFn`, or `mutationFn` wrappers.
3333

3434
## Contract Shape
3535

@@ -131,6 +131,56 @@ client: {
131131

132132
For a `DELETE` route that returns `204`, omit `responseParser` and use `responseType: "void"`.
133133

134+
## Generated TanStack Query Helpers
135+
136+
The generator emits three TanStack-oriented surfaces:
137+
138+
| Export | Purpose |
139+
|--------|---------|
140+
| `apiQueryKeys` | Stable query key factories for GET routes. Use these for invalidation and cache reads. |
141+
| `apiQueries` | `queryOptions(...)` factories for GET routes. Pass these directly to `useQuery`, `useSuspenseQuery`, `prefetchQuery`, or `useQueries`. |
142+
| `apiMutations` | `mutationOptions(...)` factories for non-GET routes. Pass or spread these into `useMutation`. |
143+
144+
Use these helpers in UI code:
145+
146+
```tsx
147+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
148+
import {
149+
apiMutations,
150+
apiQueries,
151+
apiQueryKeys,
152+
} from "../../../generated/api-client.generated.js";
153+
154+
const itemsQuery = useQuery(apiQueries.listItems());
155+
156+
const createMutation = useMutation({
157+
...apiMutations.createItem(),
158+
onSuccess: async () => {
159+
await queryClient.invalidateQueries({ queryKey: apiQueryKeys.listItems() });
160+
},
161+
});
162+
```
163+
164+
GET routes generate `apiQueries.<functionName>()`. If the route has path params, the params become the first argument:
165+
166+
```ts
167+
useQuery(apiQueries.getItem({ id }));
168+
```
169+
170+
Non-GET routes generate `apiMutations.<functionName>()`. Mutation variables match the generated client method shape:
171+
172+
- body-only routes use the body value as mutation variables
173+
- path-param-only routes use the params object as mutation variables
174+
- routes with both path params and body use `{ params, body }`
175+
- bodyless and paramless routes use no mutation variables
176+
177+
```ts
178+
createMutation.mutate({ name: "New item", status: "draft" });
179+
deleteMutation.mutate({ id });
180+
```
181+
182+
The generated helpers call the generated `apiClient`, so response parsing and `ApiClientError` behavior stay centralized.
183+
134184
## Schema Rules
135185

136186
- Request schemas should describe the raw JSON sent by clients.
@@ -172,4 +222,4 @@ When changing contracts, verify all of the following:
172222
2. `runtime/routes.ts` has tests or integration coverage for boundary validation and status codes.
173223
3. `pnpm api:generate` updates both generated files.
174224
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`.
225+
5. UI code imports `apiQueries`, `apiMutations`, `apiQueryKeys`, `apiClient`, or generated exported types from `src/generated/api-client.generated.ts`.

scripts/generate-openapi.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ function generateClient(routes: readonly ApiRouteContract[]) {
4646
const clientRoutes = routes.filter((route) => route.client);
4747
const imports = mergeImports(clientRoutes.map((route) => route.client).filter(isDefined));
4848
const methods = clientRoutes.map(generateClientMethod).join(",\n\n");
49+
const queryRoutes = clientRoutes.filter((route) => route.method === "get");
50+
const mutationRoutes = clientRoutes.filter((route) => route.method !== "get");
4951

5052
return `${[
5153
"/* eslint-disable */",
5254
"/* This file is generated by scripts/generate-openapi.ts. Do not edit by hand. */",
5355
"",
56+
'import { mutationOptions, queryOptions } from "@tanstack/react-query";',
5457
imports,
5558
"",
5659
"export interface ApiClientOptions {",
@@ -86,6 +89,12 @@ function generateClient(routes: readonly ApiRouteContract[]) {
8689
"",
8790
"export const apiClient = createApiClient();",
8891
"",
92+
generateQueryKeyFactories(queryRoutes),
93+
"",
94+
generateQueryOptionFactories(queryRoutes),
95+
"",
96+
generateMutationOptionFactories(mutationRoutes),
97+
"",
8998
"async function request<TResponse>(",
9099
"\tfetchImpl: typeof fetch,",
91100
"\tbaseUrl: string,",
@@ -119,9 +128,59 @@ function generateClient(routes: readonly ApiRouteContract[]) {
119128
].join("\n")}\n`;
120129
}
121130

131+
function generateQueryKeyFactories(routes: readonly ApiRouteContract[]) {
132+
if (routes.length === 0) return "export const apiQueryKeys = {} as const;";
133+
134+
const entries = routes.map((route) => {
135+
const client = requiredClient(route);
136+
const params = client.pathParamsType ? `params: ${client.pathParamsType}` : "";
137+
const keyParts = client.pathParamsType
138+
? `["api", ${JSON.stringify(client.functionName)}, params] as const`
139+
: `["api", ${JSON.stringify(client.functionName)}] as const`;
140+
return `${client.functionName}: (${params}) => ${keyParts},`;
141+
});
142+
143+
return `export const apiQueryKeys = {\n${indent(entries.join("\n"), 1)}\n} as const;`;
144+
}
145+
146+
function generateQueryOptionFactories(routes: readonly ApiRouteContract[]) {
147+
if (routes.length === 0) {
148+
return "export function createApiQueryOptions(_client = apiClient) {\n\treturn {};\n}\n\nexport const apiQueries = createApiQueryOptions();";
149+
}
150+
151+
const entries = routes.map((route) => {
152+
const client = requiredClient(route);
153+
const params = client.pathParamsType
154+
? `params: ${client.pathParamsType}, options: ApiRequestOptions = {}`
155+
: "options: ApiRequestOptions = {}";
156+
const keyCall = client.pathParamsType
157+
? `apiQueryKeys.${client.functionName}(params)`
158+
: `apiQueryKeys.${client.functionName}()`;
159+
const clientCall = client.pathParamsType
160+
? `client.${client.functionName}(params, options)`
161+
: `client.${client.functionName}(options)`;
162+
return `${client.functionName}: (${params}) => queryOptions({\n\tqueryKey: ${keyCall},\n\tqueryFn: () => ${clientCall},\n}),`;
163+
});
164+
165+
return `export function createApiQueryOptions(client = apiClient) {\n\treturn {\n${indent(entries.join("\n\n"), 2)}\n\t};\n}\n\nexport const apiQueries = createApiQueryOptions();`;
166+
}
167+
168+
function generateMutationOptionFactories(routes: readonly ApiRouteContract[]) {
169+
if (routes.length === 0) {
170+
return "export function createApiMutationOptions(_client = apiClient) {\n\treturn {};\n}\n\nexport const apiMutations = createApiMutationOptions();";
171+
}
172+
173+
const entries = routes.map((route) => {
174+
const client = requiredClient(route);
175+
const mutationFn = mutationFunction(client);
176+
return `${client.functionName}: (options: ApiRequestOptions = {}) => mutationOptions({\n\tmutationKey: ["api", ${JSON.stringify(client.functionName)}] as const,\n\tmutationFn: ${mutationFn},\n}),`;
177+
});
178+
179+
return `export function createApiMutationOptions(client = apiClient) {\n\treturn {\n${indent(entries.join("\n\n"), 2)}\n\t};\n}\n\nexport const apiMutations = createApiMutationOptions();`;
180+
}
181+
122182
function generateClientMethod(route: ApiRouteContract) {
123-
const client = route.client;
124-
if (!client) throw new Error(`Route ${route.operationId} is missing client metadata.`);
183+
const client = requiredClient(route);
125184

126185
const params = methodParameters(client);
127186
const path = client.pathParamsType ? pathTemplate(route.path) : JSON.stringify(route.path);
@@ -132,6 +191,27 @@ function generateClientMethod(route: ApiRouteContract) {
132191
return `${client.functionName}(${params}): Promise<${client.responseType}> {\n\treturn request<${client.responseType}>(fetchImpl, baseUrl, ${JSON.stringify(route.method.toUpperCase())}, ${path}, options${bodyArg}${parseArg});\n}`;
133192
}
134193

194+
function requiredClient(route: ApiRouteContract) {
195+
if (!route.client) throw new Error(`Route ${route.operationId} is missing client metadata.`);
196+
return route.client;
197+
}
198+
199+
function mutationFunction(client: ApiClientContract) {
200+
if (client.pathParamsType && client.requestBodyType) {
201+
return `(variables: { params: ${client.pathParamsType}; body: ${client.requestBodyType} }) => client.${client.functionName}(variables.params, variables.body, options)`;
202+
}
203+
204+
if (client.pathParamsType) {
205+
return `(params: ${client.pathParamsType}) => client.${client.functionName}(params, options)`;
206+
}
207+
208+
if (client.requestBodyType) {
209+
return `(body: ${client.requestBodyType}) => client.${client.functionName}(body, options)`;
210+
}
211+
212+
return `() => client.${client.functionName}(options)`;
213+
}
214+
135215
function methodParameters(client: ApiClientContract) {
136216
const params: string[] = [];
137217
if (client.pathParamsType) params.push(`params: ${client.pathParamsType}`);

src/domains/example/ui/item-list.tsx

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,37 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1212
import { useState } from "react";
1313
import {
1414
ApiClientError,
15-
apiClient,
16-
type ItemResponse,
15+
apiMutations,
16+
apiQueries,
17+
apiQueryKeys,
1718
} from "../../../generated/api-client.generated.js";
1819

19-
const itemsQueryKey = ["items"] as const;
20-
2120
export function ItemList() {
2221
const [newName, setNewName] = useState("");
2322
const queryClient = useQueryClient();
2423

25-
const itemsQuery = useQuery({
26-
queryKey: itemsQueryKey,
27-
queryFn: fetchItems,
28-
});
24+
const itemsQuery = useQuery(apiQueries.listItems());
2925

3026
const createMutation = useMutation({
31-
mutationFn: createItemRequest,
27+
...apiMutations.createItem(),
3228
onSuccess: async () => {
3329
setNewName("");
34-
await queryClient.invalidateQueries({ queryKey: itemsQueryKey });
30+
await queryClient.invalidateQueries({ queryKey: apiQueryKeys.listItems() });
3531
},
3632
});
3733

3834
const deleteMutation = useMutation({
39-
mutationFn: deleteItemRequest,
35+
...apiMutations.deleteItem(),
4036
onSuccess: async () => {
41-
await queryClient.invalidateQueries({ queryKey: itemsQueryKey });
37+
await queryClient.invalidateQueries({ queryKey: apiQueryKeys.listItems() });
4238
},
4339
});
4440

4541
const error = getErrorMessage(itemsQuery.error ?? createMutation.error ?? deleteMutation.error);
4642

4743
function createItem() {
4844
if (!newName.trim()) return;
49-
createMutation.mutate(newName);
45+
createMutation.mutate({ name: newName, status: "draft" });
5046
}
5147

5248
if (itemsQuery.isLoading) return <p>Loading...</p>;
@@ -93,7 +89,7 @@ export function ItemList() {
9389
</span>
9490
<button
9591
type="button"
96-
onClick={() => deleteMutation.mutate(item.id)}
92+
onClick={() => deleteMutation.mutate({ id: item.id })}
9793
style={{ color: "red", cursor: "pointer" }}
9894
>
9995
Delete
@@ -106,18 +102,6 @@ export function ItemList() {
106102
);
107103
}
108104

109-
async function fetchItems(): Promise<ItemResponse[]> {
110-
return apiClient.listItems();
111-
}
112-
113-
async function createItemRequest(name: string) {
114-
return apiClient.createItem({ name, status: "draft" });
115-
}
116-
117-
async function deleteItemRequest(id: string) {
118-
await apiClient.deleteItem({ id });
119-
}
120-
121105
function getErrorMessage(error: unknown) {
122106
if (!error) return null;
123107
if (error instanceof ApiClientError) return `HTTP ${error.status}`;

src/generated/api-client.generated.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable */
22
/* This file is generated by scripts/generate-openapi.ts. Do not edit by hand. */
33

4+
import { mutationOptions, queryOptions } from "@tanstack/react-query";
45
import type { CreateItem, ItemResponse } from "../domains/example/types/index.js";
56
export type { CreateItem, ItemResponse } from "../domains/example/types/index.js";
67
import { ItemResponseSchema } from "../domains/example/types/index.js";
@@ -52,6 +53,43 @@ export function createApiClient(options: ApiClientOptions = {}) {
5253

5354
export const apiClient = createApiClient();
5455

56+
export const apiQueryKeys = {
57+
listItems: () => ["api", "listItems"] as const,
58+
getItem: (params: { id: string }) => ["api", "getItem", params] as const,
59+
} as const;
60+
61+
export function createApiQueryOptions(client = apiClient) {
62+
return {
63+
listItems: (options: ApiRequestOptions = {}) => queryOptions({
64+
queryKey: apiQueryKeys.listItems(),
65+
queryFn: () => client.listItems(options),
66+
}),
67+
68+
getItem: (params: { id: string }, options: ApiRequestOptions = {}) => queryOptions({
69+
queryKey: apiQueryKeys.getItem(params),
70+
queryFn: () => client.getItem(params, options),
71+
}),
72+
};
73+
}
74+
75+
export const apiQueries = createApiQueryOptions();
76+
77+
export function createApiMutationOptions(client = apiClient) {
78+
return {
79+
createItem: (options: ApiRequestOptions = {}) => mutationOptions({
80+
mutationKey: ["api", "createItem"] as const,
81+
mutationFn: (body: CreateItem) => client.createItem(body, options),
82+
}),
83+
84+
deleteItem: (options: ApiRequestOptions = {}) => mutationOptions({
85+
mutationKey: ["api", "deleteItem"] as const,
86+
mutationFn: (params: { id: string }) => client.deleteItem(params, options),
87+
}),
88+
};
89+
}
90+
91+
export const apiMutations = createApiMutationOptions();
92+
5593
async function request<TResponse>(
5694
fetchImpl: typeof fetch,
5795
baseUrl: string,

0 commit comments

Comments
 (0)