@shipworthy/openapi-tanstack-query 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shipworthy AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,593 @@
1
- # Temporary Holding Version
1
+ # @shipworthy/openapi-tanstack-query
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed [TanStack Query](https://tanstack.com/query) options, keys, and mutations
4
+ for every operation in an OpenAPI 3.x document. Use it in a React (or React
5
+ Native) app that already uses `@tanstack/react-query`. The package is a thin
6
+ adapter over [`@shipworthy/openapi-fetch`](../openapi-fetch). Types come from
7
+ `@shipworthy/openapi-typescript`. Code generation only writes a small `client.ts`
8
+ that maps each `operationId` to `[METHOD, path]`. You then pass the returned
9
+ options to TanStack's own hooks (`useQuery`, `useMutation`, `useInfiniteQuery`,
10
+ `useSuspenseQuery`, `queryClient.fetchQuery`, and so on). The package has no
11
+ hooks or React provider of its own.
12
+
13
+ ## Contents
14
+
15
+ - [Installation](#installation)
16
+ - [Quick start](#quick-start)
17
+ - [Generated output](#generated-output)
18
+ - [CLI: `openapi generate tanstack-query`](#cli-openapi-generate-tanstack-query)
19
+ - [Runtime API (`@shipworthy/openapi-tanstack-query`)](#runtime-api-shipworthyopenapi-tanstack-query)
20
+ - [Generator API (`@shipworthy/openapi-tanstack-query/generator`)](#generator-api-shipworthyopenapi-tanstack-querygenerator)
21
+ - [Cache keys](#cache-keys)
22
+ - [Requests, responses, and errors](#requests-responses-and-errors)
23
+ - [Limitations and caveats](#limitations-and-caveats)
24
+ - [Troubleshooting](#troubleshooting)
25
+ - [Key facts (for AI agents)](#key-facts-for-ai-agents)
26
+ - [License](#license)
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ bun add @shipworthy/openapi-tanstack-query @tanstack/react-query react
32
+ ```
33
+
34
+ Add the code generators as dev dependencies. `effect`,
35
+ `@shipworthy/openapi-generator`, and `@shipworthy/openapi-transformer` are
36
+ optional peer dependencies that only the `./generator` entry point needs. The
37
+ CLI installs the last two. The TypeScript generator bundles TypeScript 7's AST
38
+ modules, so your project can keep its own TypeScript version:
39
+
40
+ ```sh
41
+ bun add -d @shipworthy/openapi-cli @shipworthy/openapi-typescript effect@^4
42
+ ```
43
+
44
+ | Requirement | Value |
45
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | Peer dependencies | `@tanstack/react-query` `^5.59.0`, `react` `^18.0.0 \|\| ^19.0.0` |
47
+ | Optional peers | `effect` `^4.0.0`, `@shipworthy/openapi-generator`, and `@shipworthy/openapi-transformer`, needed only for `./generator` (the CLI and programmatic generation) |
48
+ | Runtime dependencies | `@shipworthy/openapi-fetch` (installed automatically) |
49
+ | Module format | `.` ships ESM and CommonJS, each with matching declarations (`.d.ts` and `.d.cts`). `./generator` is ESM only. |
50
+ | Entry points | `.` (runtime) and `./generator` (generator plugin), compiled JavaScript with declarations in `dist/`. A `react-native` field points at `./dist/index.js`. |
51
+ | Runtime | Any environment with `fetch`, `Request`, and `Response`, or pass your own `fetch`. No TypeScript transpilation of `node_modules` is needed. |
52
+ | TypeScript | Declarations type-check with TypeScript `5.0` and later, using `NodeNext` (CommonJS or ESM) or `bundler` module resolution. |
53
+ | CLI runtime | The `openapi` binary from `@shipworthy/openapi-cli` runs on **Node.js** `>=22.19.0` or **Bun** (`bunx --bun @shipworthy/openapi-cli`). |
54
+ | OpenAPI versions | 3.0.x and 3.1.x. 3.0 documents are upgraded to 3.1 internally. Swagger 2.x is rejected. |
55
+
56
+ The runtime entry point declares no Node.js `engines` range. The CLI runtime requirement applies to code generation.
57
+
58
+ `@shipworthy/openapi-cli` loads the generator with
59
+ `import("@shipworthy/openapi-tanstack-query/generator")`. It loads
60
+ `@shipworthy/openapi-typescript` for `types.ts`. Both packages, plus `effect`
61
+ and `@shipworthy/openapi-generator`, must be resolvable from the directory where
62
+ you run the CLI.
63
+
64
+ ## Quick start
65
+
66
+ **1. Generate the client** from a local file or a URL. JSON and YAML both work,
67
+ and the CLI resolves `$ref`s:
68
+
69
+ ```sh
70
+ bunx --bun @shipworthy/openapi-cli generate tanstack-query ./openapi.json -o src/generated --name hotels
71
+ # Or generate from a URL:
72
+ bunx --bun @shipworthy/openapi-cli generate tanstack-query https://api.example.com/openapi.json -o src/generated --name hotels
73
+ ```
74
+
75
+ This writes `src/generated/hotels/types.ts` (via `@shipworthy/openapi-typescript`)
76
+ and `src/generated/hotels/client.ts`.
77
+
78
+ **2. Create the client and query utils once**:
79
+
80
+ ```ts
81
+ // src/api.ts
82
+ import { createHotelsApiClient, createHotelsApiQueryUtils } from "./generated/hotels/client";
83
+
84
+ export const hotelsClient = createHotelsApiClient({
85
+ baseUrl: "https://hotels.example.com",
86
+ authToken: () => getHotelsAccessToken(), // string, or a sync/async getter; called per request
87
+ });
88
+
89
+ export const hotelsApi = createHotelsApiQueryUtils(hotelsClient);
90
+ ```
91
+
92
+ **3. Pass the utils to TanStack Query hooks** inside your app's existing
93
+ `QueryClientProvider`:
94
+
95
+ ```tsx
96
+ import { useMutation, useQuery } from "@tanstack/react-query";
97
+ import { hotelsApi } from "./api";
98
+
99
+ function Hotels() {
100
+ const hotels = useQuery(
101
+ hotelsApi.getHotels.queryOptions({ input: { query: { country: "US", min_stars: 5 } } }),
102
+ );
103
+
104
+ const booking = useMutation(hotelsApi.createBooking.mutationOptions());
105
+ // booking.mutate({ body: bookingInput, header: { "Idempotency-Key": attemptKey } });
106
+
107
+ return <pre>{JSON.stringify(hotels.data, null, 2)}</pre>;
108
+ }
109
+ ```
110
+
111
+ Inputs, response data, and error types are all inferred from the OpenAPI
112
+ document. You do not write type arguments.
113
+
114
+ ## Generated output
115
+
116
+ `openapi generate tanstack-query <input> -o <dir> --name <name>` writes the
117
+ following into `<dir>/<name>/`:
118
+
119
+ - `types.ts`: `paths`, `components`, `operations`, and `webhooks` types from
120
+ `@shipworthy/openapi-typescript`. This file is regenerated only when it is
121
+ missing or stale (see [`--force`](#cli-openapi-generate-tanstack-query)).
122
+ - `client.ts`: overwritten on every successful run. For `--name my-pets`
123
+ (prefix `MyPets`), it contains:
124
+
125
+ ```ts
126
+ /**
127
+ * This file was auto-generated by @shipworthy/openapi-tanstack-query.
128
+ * Do not make direct changes to the file.
129
+ *
130
+ * @shipworthy/openapi-tanstack-query@0.0.1:<sha256 of schema contents>
131
+ */
132
+
133
+ import {
134
+ createApiClient,
135
+ createTanstackQueryUtils,
136
+ type TanstackQueryUtilsOptions,
137
+ } from "@shipworthy/openapi-tanstack-query";
138
+ import type { operations, paths } from "./types";
139
+
140
+ export type {
141
+ paths as MyPetsApiPaths,
142
+ components as MyPetsApiComponents,
143
+ operations as MyPetsApiOperations,
144
+ } from "./types";
145
+ export const createMyPetsApiClient: typeof createApiClient<paths> = createApiClient<paths>;
146
+ export type MyPetsApiClient = ReturnType<typeof createMyPetsApiClient>;
147
+
148
+ const routes = {
149
+ getPet: ["GET", "/pets/{id}"],
150
+ createPet: ["POST", "/pets"],
151
+ } as const;
152
+
153
+ export function createMyPetsApiQueryUtils(
154
+ client: MyPetsApiClient,
155
+ options?: Partial<TanstackQueryUtilsOptions>,
156
+ ) {
157
+ return createTanstackQueryUtils<Pick<operations, keyof typeof routes>>(client, routes, {
158
+ prefix: "my-pets",
159
+ ...options,
160
+ });
161
+ }
162
+ ```
163
+
164
+ Naming rules:
165
+
166
+ - `<Prefix>` is the PascalCase form of `--name`. `-` and `_` separators are
167
+ removed and the next character is uppercased: `hotels` becomes `Hotels`, and
168
+ `my-pets` becomes `MyPets`.
169
+ - The generated exports are `create<Prefix>ApiClient`, `<Prefix>ApiClient`
170
+ (type), `create<Prefix>ApiQueryUtils`, `<Prefix>ApiPaths`,
171
+ `<Prefix>ApiComponents`, and `<Prefix>ApiOperations`.
172
+ - The default cache-key prefix is the raw `--name` value, such as `"my-pets"`.
173
+ - Each operation is available on the query utils object by its `operationId`,
174
+ for example `hotelsApi.getHotel`.
175
+
176
+ ## CLI: `openapi generate tanstack-query`
177
+
178
+ The command is provided by `@shipworthy/openapi-cli`.
179
+
180
+ ```
181
+ openapi generate tanstack-query [flags] <input>
182
+ ```
183
+
184
+ | Argument / flag | Type | Default | Description |
185
+ | ---------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | `<input>` | string | none | OpenAPI file path, or an `http:`, `https:`, or `file:` URL. Glob patterns (`*`) are rejected. |
187
+ | `--output`, `-o` | string | none | Directory that will contain `<name>/client.ts`. |
188
+ | `--name` | string | none | Client name. It sets the output subdirectory, the PascalCase export prefix, and the default cache-key prefix. It must match `^[a-zA-Z][a-zA-Z0-9]*(?:[-_][a-zA-Z0-9]+)*$`. |
189
+ | `--force` | boolean | `false` | Regenerate `types.ts` even when its stamp matches. |
190
+
191
+ `types.ts` is regenerated when it is missing, or when the
192
+ `@shipworthy/openapi-typescript@<version>:<sha256>` stamp in its header no
193
+ longer matches. The hash covers the schema contents, not the schema's location,
194
+ so moving the file does not trigger a rebuild.
195
+
196
+ Example `package.json` script:
197
+
198
+ ```json
199
+ {
200
+ "scripts": {
201
+ "generate": "openapi generate tanstack-query openapi.json -o src/generated --name hotels"
202
+ }
203
+ }
204
+ ```
205
+
206
+ Generation fails with exit code 1, and `client.ts` is left untouched, when:
207
+
208
+ | Error message | Cause |
209
+ | -------------------------------------- | -------------------------------------------------------- |
210
+ | `<METHOD> <path>: Missing operationId` | An operation has no `operationId`. |
211
+ | `Duplicate operationId: <id>` | Two operations share an `operationId`. |
212
+ | `Unsupported operationId: <id>` | The id is `__proto__`, `constructor`, or `prototype`. |
213
+ | `Unsupported Swagger version: 2.x. …` | The input is Swagger 2.x. Only OpenAPI 3.x is supported. |
214
+ | `Globbing is unsupported.` | `<input>` contains `*`. |
215
+
216
+ ## Runtime API (`@shipworthy/openapi-tanstack-query`)
217
+
218
+ Most apps use the generated `create<Prefix>ApiClient` and
219
+ `create<Prefix>ApiQueryUtils` wrappers. The underlying exports are listed below.
220
+
221
+ ### `createApiClient<Paths>(options): ApiClient<Paths>`
222
+
223
+ ```ts
224
+ function createApiClient<Paths extends {}>(options: ApiClientOptions): Client<Paths>;
225
+
226
+ interface ApiClientOptions extends Omit<ClientOptions, "baseUrl"> {
227
+ baseUrl: string; // required
228
+ authToken?: AuthToken | AuthTokenProvider;
229
+ }
230
+ type AuthToken = string | undefined | null;
231
+ type AuthTokenProvider = () => AuthToken | Promise<AuthToken>;
232
+ type ApiClient<Paths extends {} = {}> = Client<Paths>; // @shipworthy/openapi-fetch Client
233
+ ```
234
+
235
+ This function creates an `@shipworthy/openapi-fetch` client.
236
+
237
+ - **`baseUrl`** (required): the root URL for all requests. A trailing slash is
238
+ removed.
239
+ - **`authToken`** (optional): a token string, or a sync or async getter.
240
+ - The getter runs **before every request**.
241
+ - A truthy token sets `Authorization: Bearer <token>`.
242
+ - A falsy token (`""`, `null`, `undefined`) _removes_ any `Authorization`
243
+ header.
244
+ - If you omit `authToken`, no auth middleware is installed.
245
+ - If the request's signal is already aborted after the token resolves, the
246
+ request fails with an `AbortError`.
247
+ - All other `ClientOptions` from `@shipworthy/openapi-fetch` are passed
248
+ through, including `fetch`, `Request`, `headers`, `querySerializer`,
249
+ `bodySerializer`, `pathSerializer`, `requestInitExt`, and standard
250
+ `RequestInit` fields such as `credentials`.
251
+
252
+ The returned client is a full `openapi-fetch` client. You can call it directly
253
+ (`client.GET("/hotels", { params: { query } })`) or add middleware with
254
+ `client.use(...)`.
255
+
256
+ ```ts
257
+ const client = createHotelsApiClient({
258
+ baseUrl: "https://hotels.example.com",
259
+ authToken: async () => (await session.get())?.accessToken,
260
+ headers: { "X-App-Version": "1.4.0" },
261
+ querySerializer: { array: { style: "form", explode: false } },
262
+ fetch: (request) => fetch(request), // e.g. a custom or instrumented fetch
263
+ });
264
+ ```
265
+
266
+ ### `createTanstackQueryUtils<Operations>(client, routes, options)`
267
+
268
+ ```ts
269
+ function createTanstackQueryUtils<Operations extends Record<string, any>>(
270
+ client: Client<any>,
271
+ routes: { [Name in keyof Operations]: OperationRoute },
272
+ options: TanstackQueryUtilsOptions,
273
+ ): { [Name in keyof Operations]: OperationQueryUtils<Operations[Name]> };
274
+
275
+ type OperationRoute = readonly [Uppercase<HttpMethod>, string]; // e.g. ["GET", "/hotels/{hotel_id}"]
276
+ interface TanstackQueryUtilsOptions {
277
+ prefix: string | readonly string[]; // cache-key namespace
278
+ }
279
+ ```
280
+
281
+ This function builds one `OperationQueryUtils` object per route. The generated
282
+ `create<Prefix>ApiQueryUtils(client, options?)` calls it with the generated
283
+ `routes` and `{ prefix: "<name>" }`. Pass `options.prefix` to override the
284
+ namespace. This is useful when two backends with the same API share one
285
+ `QueryClient`:
286
+
287
+ ```ts
288
+ const staging = createHotelsApiQueryUtils(stagingClient, { prefix: "hotels-staging" });
289
+ const nested = createHotelsApiQueryUtils(client, { prefix: ["tenant", tenantId, "hotels"] });
290
+ ```
291
+
292
+ You can also call it by hand with your own `operations` type:
293
+
294
+ ```ts
295
+ import { createApiClient, createTanstackQueryUtils } from "@shipworthy/openapi-tanstack-query";
296
+ import type { operations, paths } from "./types"; // from @shipworthy/openapi-typescript
297
+
298
+ const client = createApiClient<paths>({ baseUrl: "https://api.example.com" });
299
+ const api = createTanstackQueryUtils<Pick<operations, "getPet">>(
300
+ client,
301
+ { getPet: ["GET", "/pets/{id}"] },
302
+ { prefix: "pets" },
303
+ );
304
+ ```
305
+
306
+ ### `OperationQueryUtils<Op>`
307
+
308
+ Every operation (`api.<operationId>`) exposes these members. In the table below,
309
+ `Input` is `OperationInput<Op>`, `Data` is `OperationData<Op>`, and `Error` is
310
+ `OperationError<Op>`.
311
+
312
+ | Member | Returns |
313
+ | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
+ | `queryOptions({ input, ...opts })` | `{ ...opts, queryKey, queryFn }` for `useQuery`, `useSuspenseQuery`, `useQueries`, `queryClient.fetchQuery` / `prefetchQuery` / `ensureQueryData`. `opts` accepts any `UseQueryOptions` except `queryKey` and `queryFn`, such as `select`, `staleTime`, and `enabled`. `input` may be `skipToken`. |
315
+ | `queryKey({ input })` | The exact query key, tagged with `Data` for `getQueryData` and `setQueryData`. |
316
+ | `infiniteOptions({ input, initialPageParam, ...opts })` | `{ ...opts, initialPageParam, queryKey, queryFn }` for `useInfiniteQuery`, `useSuspenseInfiniteQuery`, and `queryClient.fetchInfiniteQuery`. `input` is `(pageParam) => Input`, or `skipToken`. Requires TanStack's `getNextPageParam`. |
317
+ | `infiniteKey({ input, initialPageParam })` | The exact infinite key, tagged with `InfiniteData<Data, Page>`. |
318
+ | `mutationOptions(opts?)` | `{ ...opts, mutationKey, mutationFn }` for `useMutation`. `opts` accepts any `UseMutationOptions` except `mutationKey` and `mutationFn`, such as `onSuccess` and `retry`. The mutation variables are `Input` with **no** `input` wrapper. |
319
+ | `mutationKey()` | The mutation key. |
320
+ | `key({ type?, input? })` | A key prefix for partial matching, such as `invalidateQueries`, `removeQueries`, or `isFetching`. `type` is `"query" \| "infinite" \| "mutation"`. `input` may be a deep-partial `Input`. |
321
+
322
+ When an operation has no required parameters or body, `input` and the
323
+ options argument are optional. For example,
324
+ `api.listHotels.queryOptions()` is valid.
325
+
326
+ ```ts
327
+ import {
328
+ skipToken,
329
+ useInfiniteQuery,
330
+ useMutation,
331
+ useQuery,
332
+ useQueryClient,
333
+ useSuspenseQuery,
334
+ } from "@tanstack/react-query";
335
+
336
+ // select narrows the hook's data; the cache keeps the full response
337
+ const hotels = useQuery(
338
+ hotelsApi.getHotels.queryOptions({
339
+ input: { query: { country: "US", min_stars: 5 } },
340
+ select: (response) => response.data,
341
+ staleTime: 60_000,
342
+ }),
343
+ );
344
+
345
+ // Disabled until hotelId exists. With skipToken there is no fetch and no authToken call.
346
+ const hotel = useQuery(
347
+ hotelsApi.getHotel.queryOptions({
348
+ input: hotelId ? { path: { hotel_id: hotelId } } : skipToken,
349
+ }),
350
+ );
351
+
352
+ // Suspense: always pass a real input (see caveats)
353
+ const { data } = useSuspenseQuery(
354
+ hotelsApi.getHotel.queryOptions({ input: { path: { hotel_id: "h_123" } } }),
355
+ );
356
+
357
+ // Infinite / cursor pagination
358
+ const pages = useInfiniteQuery(
359
+ hotelsApi.getHotels.infiniteOptions({
360
+ input: (cursor: string | undefined) => ({ query: { country: "US", cursor } }),
361
+ initialPageParam: undefined,
362
+ getNextPageParam: (response) => response.pagination.next_cursor ?? undefined,
363
+ }),
364
+ );
365
+
366
+ // Mutation: variables are the grouped input (path/query/header/cookie/body)
367
+ const queryClient = useQueryClient();
368
+ const cancel = useMutation(
369
+ hotelsApi.cancelBooking.mutationOptions({
370
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: hotelsApi.getBookings.key() }),
371
+ }),
372
+ );
373
+ cancel.mutate({ path: { booking_id: bookingId } });
374
+
375
+ // Prefetch outside React
376
+ await queryClient.prefetchQuery(
377
+ hotelsApi.getHotels.queryOptions({ input: { query: { country: "US" } } }),
378
+ );
379
+ ```
380
+
381
+ ### Type helpers
382
+
383
+ ```ts
384
+ type OperationInput<Op>; // Op["parameters"] groups (path, query, header, cookie) & { body } from requestBody
385
+ type OperationData<Op>; // union of all documented 2xx response bodies; empty/no-content → null
386
+ type OperationError<Op>; // union of documented error response bodies | Error
387
+ ```
388
+
389
+ Use them with the generated `<Prefix>ApiOperations` type:
390
+
391
+ ```ts
392
+ import type { OperationData, OperationInput } from "@shipworthy/openapi-tanstack-query";
393
+ import type { HotelsApiOperations } from "./generated/hotels/client";
394
+
395
+ type GetHotelInput = OperationInput<HotelsApiOperations["getHotel"]>; // { path: { hotel_id: string }, ... }
396
+ type Hotel = OperationData<HotelsApiOperations["getHotel"]>;
397
+ ```
398
+
399
+ `OperationInput` keeps OpenAPI's parameter groups. Required groups and fields
400
+ are required, and `body` is required when the `requestBody` is required.
401
+
402
+ ## Cache keys
403
+
404
+ Keys have the form `[...prefix, operationId, filter?]`:
405
+
406
+ | Call | Key |
407
+ | --------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
408
+ | `api.getPet.key()` | `["pets", "getPet"]` |
409
+ | `api.getPet.key({ type: "query" })` | `["pets", "getPet", { type: "query" }]` |
410
+ | `api.getPet.queryKey()` | `["pets", "getPet", { type: "query", input: {} }]` |
411
+ | `api.getPet.queryKey({ input: { path: { id: 1 } } })` | `["pets", "getPet", { type: "query", input: { path: { id: 1 } } }]` |
412
+ | `api.getPet.queryOptions({ input: skipToken }).queryKey` | `["pets", "getPet", { type: "query", skipped: true }]` |
413
+ | `api.list.infiniteKey({ input: (c) => ({ query: { c } }), initialPageParam: 0 })` | `["pets", "list", { type: "infinite", input: { query: { c: 0 } } }]` |
414
+ | `api.create.mutationKey()` | `["pets", "create", { type: "mutation" }]` |
415
+ | prefix `["a", "b"]`, `api.getPet.key()` | `["a", "b", "getPet"]` |
416
+
417
+ Rules:
418
+
419
+ - Because TanStack matches keys by prefix, `key()` matches every query,
420
+ infinite query, and mutation for that operation. Add `type` or `input` to
421
+ narrow the match.
422
+ - Regular and infinite queries never share a key.
423
+ - An infinite key uses the input computed from `initialPageParam`. Keep the
424
+ `input` function pure.
425
+ - Header normalization applies to the `input` in cache keys only. It does not
426
+ change what is sent:
427
+ - `Authorization`, `Proxy-Authorization`, `Cookie`, and `Set-Cookie` are
428
+ dropped.
429
+ - `undefined` header values are dropped.
430
+ - Remaining header names are lowercased.
431
+ - If no headers remain, the `header` group is omitted.
432
+
433
+ ```ts
434
+ queryClient.invalidateQueries({ queryKey: hotelsApi.getHotel.key() });
435
+ queryClient.invalidateQueries({ queryKey: hotelsApi.getHotel.key({ type: "query" }) });
436
+ queryClient.invalidateQueries({
437
+ queryKey: hotelsApi.getHotel.key({ input: { path: { hotel_id: hotelId } } }),
438
+ });
439
+ queryClient.setQueryData(
440
+ hotelsApi.getHotel.queryKey({ input: { path: { hotel_id: hotelId } } }),
441
+ (old) => old, // `old` is typed as the operation's data
442
+ );
443
+ ```
444
+
445
+ ## Requests, responses, and errors
446
+
447
+ - **Request mapping.** Input `{ body, ...groups }` is sent as
448
+ `client[METHOD](path, { params: groups, body, signal })`. Serialization
449
+ follows `@shipworthy/openapi-fetch`.
450
+ - **Request body.** An omitted optional body is not sent. An explicit `{}` is
451
+ sent as `{}`. Bodies need not be objects; the generated types set their shape.
452
+ - **Validation.** The adapter does not validate or filter payloads.
453
+ - **Cancellation.** Query `AbortSignal`s are passed to `fetch`, so TanStack
454
+ cancellation aborts in-flight requests. Mutations are not given a signal.
455
+ - **Success.** The parsed body is returned with its response envelope intact.
456
+ An empty successful body, such as a `204`, resolves to `null`, not
457
+ `undefined`.
458
+ - **HTTP failure** (`!response.ok`). The query or mutation rejects with the
459
+ **parsed error body**, which is often a plain object rather than an `Error`.
460
+ If the error body is empty, it rejects with
461
+ `new Error("HTTP <status> <statusText>")`.
462
+ - **Transport failure.** The original `Error` from `fetch` or middleware is
463
+ rethrown.
464
+
465
+ ```ts
466
+ const hotel = useQuery(hotelsApi.getHotel.queryOptions({ input: { path: { hotel_id } } }));
467
+ if (hotel.error && !(hotel.error instanceof Error)) {
468
+ // typed as the documented error response body, e.g. { code: string; message: string }
469
+ console.warn(hotel.error);
470
+ }
471
+ ```
472
+
473
+ ## Generator API (`@shipworthy/openapi-tanstack-query/generator`)
474
+
475
+ `@shipworthy/openapi-cli` uses these exports. Call them directly only if you
476
+ are building your own generator pipeline. They are built on
477
+ [Effect](https://effect.website) 4 and take a `ParsedService` from
478
+ `@shipworthy/openapi-transformer`.
479
+
480
+ ```ts
481
+ import {
482
+ clientSource,
483
+ operationRoutes,
484
+ tanstackQuery,
485
+ TanstackGenerationError,
486
+ } from "@shipworthy/openapi-tanstack-query/generator";
487
+ ```
488
+
489
+ | Export | Signature / description |
490
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
491
+ | `tanstackQuery` | `Generator<string, TanstackGenerationError>` (from `@shipworthy/openapi-generator`) with `id: "tanstack-query"`. `generate(model, options)` returns `Effect<string, TanstackGenerationError>`, which is the `client.ts` source with no header comment. Options: `prefix?: string` (export prefix, default `""`) and `name?: string` (cache-key prefix, default `prefix`). Other option shapes fail with `TanstackGenerationError`. |
492
+ | `operationRoutes(model)` | `Effect<Record<string, OperationRoute>, TanstackGenerationError>`. Maps each `operationId` to `[METHOD, path]`. Fails on a missing, duplicate, or reserved (`__proto__`, `constructor`, `prototype`) `operationId`. |
493
+ | `clientSource(prefix, name, routes)` | `string`. Prints `client.ts` source for the given export prefix, cache-key prefix, and routes. |
494
+ | `TanstackGenerationError` | Effect `Schema.TaggedError` with `_tag: "TanstackGenerationError"` and `message: string`. |
495
+
496
+ Programmatic generation with `@shipworthy/openapi-generator`, which is what
497
+ the CLI does:
498
+
499
+ Install `@effect/platform-node@^4` as a dev dependency for the Node.js platform layer used in this example:
500
+
501
+ ```ts
502
+ import { NodeServices } from "@effect/platform-node";
503
+ import { generate, ModuleLoader } from "@shipworthy/openapi-generator";
504
+ import { Effect, Layer } from "effect";
505
+ import { FetchHttpClient } from "effect/http";
506
+
507
+ const program = generate({
508
+ specifier: "@shipworthy/openapi-tanstack-query/generator",
509
+ exportName: "tanstackQuery",
510
+ source: new URL("file:///abs/path/openapi.json"),
511
+ pluginOptions: { prefix: "Hotels", name: "hotels" },
512
+ }).pipe(
513
+ Effect.provideService(ModuleLoader, { load: (specifier) => import(specifier) }),
514
+ Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
515
+ );
516
+
517
+ const source = await Effect.runPromise(program); // client.ts source string at runtime
518
+ ```
519
+
520
+ This program writes neither `types.ts` nor the stamp header. The CLI adds
521
+ both.
522
+
523
+ ## Limitations and caveats
524
+
525
+ Generated files use extensionless `./types` imports and expect TypeScript `moduleResolution: "bundler"`. Direct `NodeNext` compilation of those generated files is not currently supported. This does not affect the package's compiled exports.
526
+
527
+ - **Every operation needs a unique `operationId`.** Generation fails otherwise.
528
+ - **The package has no provider or hooks.** Use your app's existing
529
+ `QueryClientProvider` and TanStack's hooks.
530
+ - **Auth is the app's job.** The app owns token exchange and refresh, and it
531
+ clears account-specific caches on logout. `authToken` only reads a token for
532
+ each request.
533
+ - **Idempotency keys are not generated or persisted.** If an API needs them,
534
+ pass them in `header`. Reuse the same key for every retry of one logical
535
+ attempt.
536
+ - **Do not pass `skipToken` options to suspense hooks.** TanStack Query's
537
+ types allow it, but suspense hooks reject it at runtime.
538
+ - **Serializers are not generated per schema.** If a query parameter needs a
539
+ custom encoding, set `querySerializer` in `createApiClient`.
540
+ - **Cookies are handled by `fetch`.** Cookie behavior follows
541
+ `@shipworthy/openapi-fetch` and your `fetch` implementation.
542
+
543
+ ## Troubleshooting
544
+
545
+ | Symptom | Fix |
546
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
547
+ | `<METHOD> <path>: Missing operationId` / `Duplicate operationId: …` | Add unique `operationId`s to the OpenAPI document. |
548
+ | `--name must start with a letter …` | Use letters and digits, with single `-` or `_` separators, such as `hotels` or `sdk-core`. |
549
+ | `types.ts` was not regenerated after editing generator settings | It is regenerated only when the schema hash or generator version changes. Pass `--force`. |
550
+ | CLI cannot load `@shipworthy/openapi-tanstack-query/generator` | Install this package, `@shipworthy/openapi-typescript`, `@shipworthy/openapi-generator`, and `effect` in the project. |
551
+ | `openapi` fails to start on an older Node.js | The CLI needs Node.js `>=22.19.0`. Upgrade Node.js, or run it with `bunx --bun @shipworthy/openapi-cli`. |
552
+ | `error instanceof Error` is false in `onError` | This is expected for HTTP errors: the error is the parsed error body. See [errors](#requests-responses-and-errors). |
553
+ | Two APIs with the same `--name` overwrite each other's cache entries | Pass a different `prefix` to `create<Prefix>ApiQueryUtils(client, { prefix })`. |
554
+
555
+ ## Key facts (for AI agents)
556
+
557
+ - **Install:** `@shipworthy/openapi-tanstack-query`, plus peers
558
+ `@tanstack/react-query@^5.59.0` and `react@^18 || ^19`. The runtime entry
559
+ ships ESM and CommonJS with `.d.ts`/`.d.cts` declarations for TypeScript 5.0+.
560
+ For `./generator`, also install the optional peers `effect@^4`,
561
+ `@shipworthy/openapi-generator`, and `@shipworthy/openapi-transformer`.
562
+ - **Generate:** run
563
+ `openapi generate tanstack-query <spec> -o <dir> --name <name>` from
564
+ `@shipworthy/openapi-cli` (`bunx --bun @shipworthy/openapi-cli`). The output is
565
+ `<dir>/<name>/types.ts` and `<dir>/<name>/client.ts`. Do not edit generated
566
+ files.
567
+ - **Generated exports** (Prefix is the PascalCase of `name`):
568
+ `create<Prefix>ApiClient`, `create<Prefix>ApiQueryUtils`,
569
+ `<Prefix>ApiClient`, `<Prefix>ApiPaths`, `<Prefix>ApiComponents`, and
570
+ `<Prefix>ApiOperations`.
571
+ - **Setup:**
572
+ ```ts
573
+ const api = createHotelsApiQueryUtils(createHotelsApiClient({ baseUrl, authToken }));
574
+ ```
575
+ `api.<operationId>` exposes `queryOptions`, `queryKey`, `infiniteOptions`,
576
+ `infiniteKey`, `mutationOptions`, `mutationKey`, and `key`.
577
+ - **Query input:** `{ input: { path?, query?, header?, cookie?, body? } }`, or
578
+ `input: skipToken` to disable the query.
579
+ - **Mutation variables:** the grouped input directly, with no `input` wrapper.
580
+ - **Infinite queries:** `input` is `(pageParam) => Input`. Supply
581
+ `initialPageParam` and `getNextPageParam`.
582
+ - **Data and errors:**
583
+ - Data is the full 2xx body. An empty body becomes `null`.
584
+ - HTTP errors throw the parsed error body. An empty error body throws
585
+ `Error("HTTP <status> <statusText>")`.
586
+ - **Keys:** `[...prefix, operationId, { type, input }]`. Use `key()` for
587
+ broad invalidation. Auth and cookie headers are excluded from keys.
588
+ - **Not provided:** hooks, provider, payload validation, token refresh,
589
+ idempotency-key generation, and per-schema serializers.
590
+
591
+ ## License
592
+
593
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,19 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { Generator } from "@shipworthy/openapi-generator";
3
+ import { ParsedService } from "@shipworthy/openapi-transformer";
4
+ import { HttpMethod } from "@shipworthy/openapi-fetch";
5
+ import "@tanstack/react-query";
6
+ //#region src/errors.d.ts
7
+ declare const TanstackGenerationError_base: Schema.Class<TanstackGenerationError, Schema.TaggedStruct<"TanstackGenerationError", {
8
+ readonly message: Schema.String;
9
+ }>, import("effect/Cause").YieldableError>;
10
+ export declare class TanstackGenerationError extends TanstackGenerationError_base {}
11
+ //#endregion
12
+ //#region src/query-utils.d.ts
13
+ type OperationRoute = readonly [Uppercase<HttpMethod>, string];
14
+ //#endregion
15
+ //#region src/generator.d.ts
16
+ /** operationId → [METHOD, path]. Every operation needs an id. */
17
+ export declare const operationRoutes: (model: ParsedService) => Effect.Effect<Record<string, OperationRoute>, TanstackGenerationError, never>;
18
+ export declare const tanstackQuery: Generator<string, TanstackGenerationError>;
19
+ //#endregion
@@ -0,0 +1,57 @@
1
+ import { Effect, Schema } from "effect";
2
+ //#region src/errors.ts
3
+ var TanstackGenerationError = class extends Schema.TaggedError()("TanstackGenerationError", { message: Schema.String }) {};
4
+ //#endregion
5
+ //#region src/generator.ts
6
+ const blockedOperationIds = /* @__PURE__ */ new Set([
7
+ "__proto__",
8
+ "constructor",
9
+ "prototype"
10
+ ]);
11
+ const PluginOptions = Schema.Struct({
12
+ prefix: Schema.optionalKey(Schema.String),
13
+ name: Schema.optionalKey(Schema.String)
14
+ });
15
+ function reject(message) {
16
+ return new TanstackGenerationError({ message });
17
+ }
18
+ /** operationId → [METHOD, path]. Every operation needs an id. */
19
+ const operationRoutes = Effect.fnUntraced(function* (model) {
20
+ const routes = Object.create(null);
21
+ return yield* Effect.reduce(model.operations, () => routes, Effect.fnUntraced(function* (routes, operation) {
22
+ const label = `${operation.method} ${operation.path}`;
23
+ const id = operation.operationId;
24
+ if (!id) return yield* reject(`${label}: Missing operationId`);
25
+ if (blockedOperationIds.has(id)) return yield* reject(`Unsupported operationId: ${id}`);
26
+ if (Object.hasOwn(routes, id)) return yield* reject(`Duplicate operationId: ${id}`);
27
+ routes[id] = [operation.method, operation.path];
28
+ return routes;
29
+ }));
30
+ });
31
+ const tanstackQuery = {
32
+ id: "tanstack-query",
33
+ generate: Effect.fnUntraced(function* (model, options) {
34
+ const decoded = yield* Effect.mapError(Schema.decodeEffect(PluginOptions)(options), (cause) => reject(cause.message));
35
+ const prefix = decoded.prefix ?? "";
36
+ return clientSource(prefix, decoded.name ?? prefix, yield* operationRoutes(model));
37
+ })
38
+ };
39
+ function clientSource(prefix, name, routes) {
40
+ return `import { createApiClient, createTanstackQueryUtils, type TanstackQueryUtilsOptions } from "@shipworthy/openapi-tanstack-query";
41
+ import type { operations, paths } from "./types";
42
+
43
+ export type { paths as ${prefix}ApiPaths, components as ${prefix}ApiComponents, operations as ${prefix}ApiOperations } from "./types";
44
+ export const create${prefix}ApiClient: typeof createApiClient<paths> = createApiClient<paths>;
45
+ export type ${prefix}ApiClient = ReturnType<typeof create${prefix}ApiClient>;
46
+
47
+ const routes = {
48
+ ${Object.entries(routes).map(([id, route]) => ` ${JSON.stringify(id)}: ${JSON.stringify(route)},`).join("\n")}
49
+ } as const;
50
+
51
+ export function create${prefix}ApiQueryUtils(client: ${prefix}ApiClient, options?: Partial<TanstackQueryUtilsOptions>) {
52
+ return createTanstackQueryUtils<Pick<operations, keyof typeof routes>>(client, routes, { prefix: ${JSON.stringify(name)}, ...options });
53
+ }
54
+ `;
55
+ }
56
+ //#endregion
57
+ export { TanstackGenerationError, operationRoutes, tanstackQuery };
package/dist/index.cjs ADDED
@@ -0,0 +1,107 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _shipworthy_openapi_fetch = require("@shipworthy/openapi-fetch");
3
+ let _tanstack_react_query = require("@tanstack/react-query");
4
+ //#region src/client.ts
5
+ function createApiClient({ authToken, ...options }) {
6
+ const client = (0, _shipworthy_openapi_fetch.createClient)(options);
7
+ if (authToken !== void 0) client.use({ async onRequest({ request }) {
8
+ const token = typeof authToken === "function" ? await authToken() : authToken;
9
+ if (request.signal?.aborted) {
10
+ const error = /* @__PURE__ */ new Error("Request aborted");
11
+ error.name = "AbortError";
12
+ throw error;
13
+ }
14
+ if (token) request.headers.set("Authorization", `Bearer ${token}`);
15
+ else request.headers.delete("Authorization");
16
+ return request;
17
+ } });
18
+ return client;
19
+ }
20
+ //#endregion
21
+ //#region src/query-utils.ts
22
+ function cacheInput(input = {}) {
23
+ const { header, ...rest } = input;
24
+ if (!header) return rest;
25
+ const entries = Object.entries(header).filter(([name, value]) => value !== void 0 && ![
26
+ "authorization",
27
+ "proxy-authorization",
28
+ "cookie",
29
+ "set-cookie"
30
+ ].includes(name.toLowerCase()));
31
+ return entries.length ? {
32
+ ...rest,
33
+ header: Object.fromEntries(entries.map(([name, value]) => [name.toLowerCase(), value]))
34
+ } : rest;
35
+ }
36
+ function createTanstackQueryUtils(client, routes, { prefix }) {
37
+ const namespace = typeof prefix === "string" ? [prefix] : [...prefix];
38
+ return Object.fromEntries(Object.entries(routes).map(([name, [method, path]]) => {
39
+ const request = async (input = {}, signal) => {
40
+ const { body, ...params } = input;
41
+ const { data, error, response } = await client[method](path, {
42
+ params,
43
+ body,
44
+ signal
45
+ });
46
+ if (!response.ok) throw error ?? /* @__PURE__ */ new Error(`HTTP ${response.status} ${response.statusText}`);
47
+ return data ?? null;
48
+ };
49
+ const key = (options = {}) => {
50
+ const { input, ...filter } = options;
51
+ if (input !== void 0) filter.input = cacheInput(input);
52
+ return Object.keys(filter).length ? [
53
+ ...namespace,
54
+ name,
55
+ filter
56
+ ] : [...namespace, name];
57
+ };
58
+ const queryKey = ({ input } = {}) => key({
59
+ type: "query",
60
+ input: input ?? {}
61
+ });
62
+ const infiniteKey = ({ input, initialPageParam }) => key({
63
+ type: "infinite",
64
+ input: input(initialPageParam)
65
+ });
66
+ const mutationKey = () => key({ type: "mutation" });
67
+ return [name, {
68
+ key,
69
+ queryKey,
70
+ infiniteKey,
71
+ mutationKey,
72
+ queryOptions({ input, ...options } = {}) {
73
+ return {
74
+ ...options,
75
+ queryKey: input === _tanstack_react_query.skipToken ? key({
76
+ type: "query",
77
+ skipped: true
78
+ }) : queryKey({ input }),
79
+ queryFn: input === _tanstack_react_query.skipToken ? _tanstack_react_query.skipToken : ({ signal }) => request(input, signal)
80
+ };
81
+ },
82
+ infiniteOptions({ input, ...options }) {
83
+ return {
84
+ ...options,
85
+ queryKey: input === _tanstack_react_query.skipToken ? key({
86
+ type: "infinite",
87
+ skipped: true
88
+ }) : infiniteKey({
89
+ input,
90
+ initialPageParam: options.initialPageParam
91
+ }),
92
+ queryFn: input === _tanstack_react_query.skipToken ? _tanstack_react_query.skipToken : ({ pageParam, signal }) => request(input(pageParam), signal)
93
+ };
94
+ },
95
+ mutationOptions(options = {}) {
96
+ return {
97
+ ...options,
98
+ mutationKey: mutationKey(),
99
+ mutationFn: (input) => request(input)
100
+ };
101
+ }
102
+ }];
103
+ }));
104
+ }
105
+ //#endregion
106
+ exports.createApiClient = createApiClient;
107
+ exports.createTanstackQueryUtils = createTanstackQueryUtils;
@@ -0,0 +1,83 @@
1
+ import { Client, ClientOptions, ErrorResponse, HttpMethod, MediaType, RequestBodyOption, RequiredKeysOf, ResponseObjectMap, SuccessResponse } from "@shipworthy/openapi-fetch";
2
+ import { DataTag, InfiniteData, InfiniteQueryPageParamsOptions, QueryFunction, QueryKey, QueryObserverOptions, SkipToken, UseMutationOptions, UseQueryOptions } from "@tanstack/react-query";
3
+ //#region src/client.d.ts
4
+ type AuthToken = string | undefined | null;
5
+ type AuthTokenProvider = () => AuthToken | Promise<AuthToken>;
6
+ interface ApiClientOptions extends Omit<ClientOptions, "baseUrl"> {
7
+ baseUrl: string;
8
+ authToken?: AuthToken | AuthTokenProvider;
9
+ }
10
+ export declare function createApiClient<Paths extends {}>({ authToken, ...options }: ApiClientOptions): Client<Paths>;
11
+ type ApiClient<Paths extends {} = {}> = Client<Paths>;
12
+ //#endregion
13
+ //#region src/operation.d.ts
14
+ /** Preserve OpenAPI's parameter groups and add its typed request body. */
15
+ type OperationInput<Op> = (Op extends {
16
+ parameters: infer Parameters;
17
+ } ? Parameters : {}) & RequestBodyOption<Op>;
18
+ type Responses<Op> = Extract<ResponseObjectMap<Op>, Record<string | number, unknown>>;
19
+ type NormalizeData<T> = [T] extends [never] ? null : Exclude<T, undefined> | (undefined extends T ? null : never);
20
+ type JsonResponses<Op> = { [Status in keyof Responses<Op>]: Responses<Op>[Status] extends {
21
+ content: {
22
+ "application/json": infer Data;
23
+ };
24
+ } ? {
25
+ content: {
26
+ "application/json": Data;
27
+ };
28
+ } : Responses<Op>[Status]; };
29
+ type OperationData<Op> = NormalizeData<SuccessResponse<JsonResponses<Op>, MediaType>>;
30
+ type OperationError<Op> = ErrorResponse<Responses<Op>, MediaType> | Error;
31
+ //#endregion
32
+ //#region src/query-utils.d.ts
33
+ type InputOf<O> = OperationInput<O>;
34
+ type DataOf<O> = OperationData<O>;
35
+ type ErrorOf<O> = OperationError<O>;
36
+ type Field<K extends string, T> = RequiredKeysOf<T> extends never ? { [P in K]?: T; } : { [P in K]: T; };
37
+ type Request<O> = Field<"input", InputOf<O>>;
38
+ type Args<T> = RequiredKeysOf<T> extends never ? [options?: T] : [options: T];
39
+ type Variables<O> = InputOf<O>;
40
+ type PartialInput<T> = T extends readonly unknown[] ? T : T extends object ? { [K in keyof T]?: PartialInput<T[K]>; } : T;
41
+ type Key<Data> = DataTag<QueryKey, Data>;
42
+ type QueryConfig<O, Selected> = Omit<UseQueryOptions<DataOf<O>, ErrorOf<O>, Selected, Key<DataOf<O>>>, "queryKey" | "queryFn">;
43
+ type QueryResult<O, Selected, Fn> = QueryConfig<O, Selected> & {
44
+ queryKey: Key<DataOf<O>>;
45
+ queryFn: Fn;
46
+ };
47
+ type InfiniteConfig<O, Page, Selected> = Omit<QueryObserverOptions<DataOf<O>, ErrorOf<O>, Selected, InfiniteData<DataOf<O>, Page>, Key<InfiniteData<DataOf<O>, Page>>, Page> & InfiniteQueryPageParamsOptions<DataOf<O>, Page>, "queryKey" | "queryFn" | "suspense">;
48
+ type InfiniteResult<O, Page, Selected, Fn> = InfiniteConfig<O, Page, Selected> & {
49
+ queryKey: Key<InfiniteData<DataOf<O>, Page>>;
50
+ queryFn: Fn;
51
+ };
52
+ type InfiniteInput<O, Page> = {
53
+ input: (pageParam: Page) => InputOf<O>;
54
+ initialPageParam: Page;
55
+ };
56
+ interface OperationQueryUtils<O> {
57
+ key(options?: {
58
+ type?: "query" | "infinite" | "mutation";
59
+ input?: PartialInput<InputOf<O>>;
60
+ }): QueryKey;
61
+ queryKey(...args: Args<Request<O>>): Key<DataOf<O>>;
62
+ queryOptions<Selected = DataOf<O>>(...args: Args<Request<O> & QueryConfig<O, Selected>>): QueryResult<O, Selected, QueryFunction<DataOf<O>, Key<DataOf<O>>>>;
63
+ queryOptions<Selected = DataOf<O>>(options: Omit<Request<O>, "input"> & {
64
+ input: InputOf<O> | SkipToken;
65
+ } & QueryConfig<O, Selected>): QueryResult<O, Selected, QueryFunction<DataOf<O>, Key<DataOf<O>>> | SkipToken>;
66
+ infiniteKey<Page>(options: InfiniteInput<O, Page>): Key<InfiniteData<DataOf<O>, Page>>;
67
+ infiniteOptions<Page, Selected = InfiniteData<DataOf<O>, Page>>(options: InfiniteInput<O, Page> & InfiniteConfig<O, Page, Selected>): InfiniteResult<O, Page, Selected, QueryFunction<DataOf<O>, Key<InfiniteData<DataOf<O>, Page>>, Page>>;
68
+ infiniteOptions<Page, Selected = InfiniteData<DataOf<O>, Page>>(options: Omit<InfiniteInput<O, Page>, "input"> & {
69
+ input: ((pageParam: Page) => InputOf<O>) | SkipToken;
70
+ } & InfiniteConfig<O, Page, Selected>): InfiniteResult<O, Page, Selected, QueryFunction<DataOf<O>, Key<InfiniteData<DataOf<O>, Page>>, Page> | SkipToken>;
71
+ mutationKey(): QueryKey;
72
+ mutationOptions<Context = unknown>(options?: Omit<UseMutationOptions<DataOf<O>, ErrorOf<O>, Variables<O>, Context>, "mutationKey" | "mutationFn">): UseMutationOptions<DataOf<O>, ErrorOf<O>, Variables<O>, Context> & {
73
+ mutationKey: QueryKey;
74
+ mutationFn: (variables: Variables<O>) => Promise<DataOf<O>>;
75
+ };
76
+ }
77
+ interface TanstackQueryUtilsOptions {
78
+ prefix: string | readonly string[];
79
+ }
80
+ type OperationRoute = readonly [Uppercase<HttpMethod>, string];
81
+ export declare function createTanstackQueryUtils<Operations extends Record<string, any>>(client: Client<any>, routes: { [Name in keyof Operations]: OperationRoute; }, { prefix }: TanstackQueryUtilsOptions): { [Name in keyof Operations]: OperationQueryUtils<Operations[Name]>; };
82
+ //#endregion
83
+ export type { ApiClient, ApiClientOptions, AuthToken, AuthTokenProvider, OperationData, OperationError, OperationInput, OperationQueryUtils, OperationRoute, TanstackQueryUtilsOptions };
@@ -0,0 +1,83 @@
1
+ import { Client, ClientOptions, ErrorResponse, HttpMethod, MediaType, RequestBodyOption, RequiredKeysOf, ResponseObjectMap, SuccessResponse } from "@shipworthy/openapi-fetch";
2
+ import { DataTag, InfiniteData, InfiniteQueryPageParamsOptions, QueryFunction, QueryKey, QueryObserverOptions, SkipToken, UseMutationOptions, UseQueryOptions } from "@tanstack/react-query";
3
+ //#region src/client.d.ts
4
+ type AuthToken = string | undefined | null;
5
+ type AuthTokenProvider = () => AuthToken | Promise<AuthToken>;
6
+ interface ApiClientOptions extends Omit<ClientOptions, "baseUrl"> {
7
+ baseUrl: string;
8
+ authToken?: AuthToken | AuthTokenProvider;
9
+ }
10
+ export declare function createApiClient<Paths extends {}>({ authToken, ...options }: ApiClientOptions): Client<Paths>;
11
+ type ApiClient<Paths extends {} = {}> = Client<Paths>;
12
+ //#endregion
13
+ //#region src/operation.d.ts
14
+ /** Preserve OpenAPI's parameter groups and add its typed request body. */
15
+ type OperationInput<Op> = (Op extends {
16
+ parameters: infer Parameters;
17
+ } ? Parameters : {}) & RequestBodyOption<Op>;
18
+ type Responses<Op> = Extract<ResponseObjectMap<Op>, Record<string | number, unknown>>;
19
+ type NormalizeData<T> = [T] extends [never] ? null : Exclude<T, undefined> | (undefined extends T ? null : never);
20
+ type JsonResponses<Op> = { [Status in keyof Responses<Op>]: Responses<Op>[Status] extends {
21
+ content: {
22
+ "application/json": infer Data;
23
+ };
24
+ } ? {
25
+ content: {
26
+ "application/json": Data;
27
+ };
28
+ } : Responses<Op>[Status]; };
29
+ type OperationData<Op> = NormalizeData<SuccessResponse<JsonResponses<Op>, MediaType>>;
30
+ type OperationError<Op> = ErrorResponse<Responses<Op>, MediaType> | Error;
31
+ //#endregion
32
+ //#region src/query-utils.d.ts
33
+ type InputOf<O> = OperationInput<O>;
34
+ type DataOf<O> = OperationData<O>;
35
+ type ErrorOf<O> = OperationError<O>;
36
+ type Field<K extends string, T> = RequiredKeysOf<T> extends never ? { [P in K]?: T; } : { [P in K]: T; };
37
+ type Request<O> = Field<"input", InputOf<O>>;
38
+ type Args<T> = RequiredKeysOf<T> extends never ? [options?: T] : [options: T];
39
+ type Variables<O> = InputOf<O>;
40
+ type PartialInput<T> = T extends readonly unknown[] ? T : T extends object ? { [K in keyof T]?: PartialInput<T[K]>; } : T;
41
+ type Key<Data> = DataTag<QueryKey, Data>;
42
+ type QueryConfig<O, Selected> = Omit<UseQueryOptions<DataOf<O>, ErrorOf<O>, Selected, Key<DataOf<O>>>, "queryKey" | "queryFn">;
43
+ type QueryResult<O, Selected, Fn> = QueryConfig<O, Selected> & {
44
+ queryKey: Key<DataOf<O>>;
45
+ queryFn: Fn;
46
+ };
47
+ type InfiniteConfig<O, Page, Selected> = Omit<QueryObserverOptions<DataOf<O>, ErrorOf<O>, Selected, InfiniteData<DataOf<O>, Page>, Key<InfiniteData<DataOf<O>, Page>>, Page> & InfiniteQueryPageParamsOptions<DataOf<O>, Page>, "queryKey" | "queryFn" | "suspense">;
48
+ type InfiniteResult<O, Page, Selected, Fn> = InfiniteConfig<O, Page, Selected> & {
49
+ queryKey: Key<InfiniteData<DataOf<O>, Page>>;
50
+ queryFn: Fn;
51
+ };
52
+ type InfiniteInput<O, Page> = {
53
+ input: (pageParam: Page) => InputOf<O>;
54
+ initialPageParam: Page;
55
+ };
56
+ interface OperationQueryUtils<O> {
57
+ key(options?: {
58
+ type?: "query" | "infinite" | "mutation";
59
+ input?: PartialInput<InputOf<O>>;
60
+ }): QueryKey;
61
+ queryKey(...args: Args<Request<O>>): Key<DataOf<O>>;
62
+ queryOptions<Selected = DataOf<O>>(...args: Args<Request<O> & QueryConfig<O, Selected>>): QueryResult<O, Selected, QueryFunction<DataOf<O>, Key<DataOf<O>>>>;
63
+ queryOptions<Selected = DataOf<O>>(options: Omit<Request<O>, "input"> & {
64
+ input: InputOf<O> | SkipToken;
65
+ } & QueryConfig<O, Selected>): QueryResult<O, Selected, QueryFunction<DataOf<O>, Key<DataOf<O>>> | SkipToken>;
66
+ infiniteKey<Page>(options: InfiniteInput<O, Page>): Key<InfiniteData<DataOf<O>, Page>>;
67
+ infiniteOptions<Page, Selected = InfiniteData<DataOf<O>, Page>>(options: InfiniteInput<O, Page> & InfiniteConfig<O, Page, Selected>): InfiniteResult<O, Page, Selected, QueryFunction<DataOf<O>, Key<InfiniteData<DataOf<O>, Page>>, Page>>;
68
+ infiniteOptions<Page, Selected = InfiniteData<DataOf<O>, Page>>(options: Omit<InfiniteInput<O, Page>, "input"> & {
69
+ input: ((pageParam: Page) => InputOf<O>) | SkipToken;
70
+ } & InfiniteConfig<O, Page, Selected>): InfiniteResult<O, Page, Selected, QueryFunction<DataOf<O>, Key<InfiniteData<DataOf<O>, Page>>, Page> | SkipToken>;
71
+ mutationKey(): QueryKey;
72
+ mutationOptions<Context = unknown>(options?: Omit<UseMutationOptions<DataOf<O>, ErrorOf<O>, Variables<O>, Context>, "mutationKey" | "mutationFn">): UseMutationOptions<DataOf<O>, ErrorOf<O>, Variables<O>, Context> & {
73
+ mutationKey: QueryKey;
74
+ mutationFn: (variables: Variables<O>) => Promise<DataOf<O>>;
75
+ };
76
+ }
77
+ interface TanstackQueryUtilsOptions {
78
+ prefix: string | readonly string[];
79
+ }
80
+ type OperationRoute = readonly [Uppercase<HttpMethod>, string];
81
+ export declare function createTanstackQueryUtils<Operations extends Record<string, any>>(client: Client<any>, routes: { [Name in keyof Operations]: OperationRoute; }, { prefix }: TanstackQueryUtilsOptions): { [Name in keyof Operations]: OperationQueryUtils<Operations[Name]>; };
82
+ //#endregion
83
+ export type { ApiClient, ApiClientOptions, AuthToken, AuthTokenProvider, OperationData, OperationError, OperationInput, OperationQueryUtils, OperationRoute, TanstackQueryUtilsOptions };
package/dist/index.js ADDED
@@ -0,0 +1,105 @@
1
+ import { createClient } from "@shipworthy/openapi-fetch";
2
+ import { skipToken } from "@tanstack/react-query";
3
+ //#region src/client.ts
4
+ function createApiClient({ authToken, ...options }) {
5
+ const client = createClient(options);
6
+ if (authToken !== void 0) client.use({ async onRequest({ request }) {
7
+ const token = typeof authToken === "function" ? await authToken() : authToken;
8
+ if (request.signal?.aborted) {
9
+ const error = /* @__PURE__ */ new Error("Request aborted");
10
+ error.name = "AbortError";
11
+ throw error;
12
+ }
13
+ if (token) request.headers.set("Authorization", `Bearer ${token}`);
14
+ else request.headers.delete("Authorization");
15
+ return request;
16
+ } });
17
+ return client;
18
+ }
19
+ //#endregion
20
+ //#region src/query-utils.ts
21
+ function cacheInput(input = {}) {
22
+ const { header, ...rest } = input;
23
+ if (!header) return rest;
24
+ const entries = Object.entries(header).filter(([name, value]) => value !== void 0 && ![
25
+ "authorization",
26
+ "proxy-authorization",
27
+ "cookie",
28
+ "set-cookie"
29
+ ].includes(name.toLowerCase()));
30
+ return entries.length ? {
31
+ ...rest,
32
+ header: Object.fromEntries(entries.map(([name, value]) => [name.toLowerCase(), value]))
33
+ } : rest;
34
+ }
35
+ function createTanstackQueryUtils(client, routes, { prefix }) {
36
+ const namespace = typeof prefix === "string" ? [prefix] : [...prefix];
37
+ return Object.fromEntries(Object.entries(routes).map(([name, [method, path]]) => {
38
+ const request = async (input = {}, signal) => {
39
+ const { body, ...params } = input;
40
+ const { data, error, response } = await client[method](path, {
41
+ params,
42
+ body,
43
+ signal
44
+ });
45
+ if (!response.ok) throw error ?? /* @__PURE__ */ new Error(`HTTP ${response.status} ${response.statusText}`);
46
+ return data ?? null;
47
+ };
48
+ const key = (options = {}) => {
49
+ const { input, ...filter } = options;
50
+ if (input !== void 0) filter.input = cacheInput(input);
51
+ return Object.keys(filter).length ? [
52
+ ...namespace,
53
+ name,
54
+ filter
55
+ ] : [...namespace, name];
56
+ };
57
+ const queryKey = ({ input } = {}) => key({
58
+ type: "query",
59
+ input: input ?? {}
60
+ });
61
+ const infiniteKey = ({ input, initialPageParam }) => key({
62
+ type: "infinite",
63
+ input: input(initialPageParam)
64
+ });
65
+ const mutationKey = () => key({ type: "mutation" });
66
+ return [name, {
67
+ key,
68
+ queryKey,
69
+ infiniteKey,
70
+ mutationKey,
71
+ queryOptions({ input, ...options } = {}) {
72
+ return {
73
+ ...options,
74
+ queryKey: input === skipToken ? key({
75
+ type: "query",
76
+ skipped: true
77
+ }) : queryKey({ input }),
78
+ queryFn: input === skipToken ? skipToken : ({ signal }) => request(input, signal)
79
+ };
80
+ },
81
+ infiniteOptions({ input, ...options }) {
82
+ return {
83
+ ...options,
84
+ queryKey: input === skipToken ? key({
85
+ type: "infinite",
86
+ skipped: true
87
+ }) : infiniteKey({
88
+ input,
89
+ initialPageParam: options.initialPageParam
90
+ }),
91
+ queryFn: input === skipToken ? skipToken : ({ pageParam, signal }) => request(input(pageParam), signal)
92
+ };
93
+ },
94
+ mutationOptions(options = {}) {
95
+ return {
96
+ ...options,
97
+ mutationKey: mutationKey(),
98
+ mutationFn: (input) => request(input)
99
+ };
100
+ }
101
+ }];
102
+ }));
103
+ }
104
+ //#endregion
105
+ export { createApiClient, createTanstackQueryUtils };
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@shipworthy/openapi-tanstack-query",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "dependencies": {
8
+ "@shipworthy/openapi-fetch": "0.0.1"
9
+ },
10
+ "peerDependencies": {
11
+ "@shipworthy/openapi-generator": "0.0.1",
12
+ "@shipworthy/openapi-transformer": "0.0.1",
13
+ "@tanstack/react-query": "^5.59.0",
14
+ "effect": "^4.0.0",
15
+ "react": "^18.0.0 || ^19.0.0"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/ShipWorthyAI/openapi.git",
21
+ "directory": "packages/openapi-tanstack-query"
22
+ },
23
+ "type": "module",
24
+ "react-native": "./dist/index.js",
25
+ "exports": {
26
+ ".": {
27
+ "import": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "require": {
32
+ "types": "./dist/index.d.cts",
33
+ "default": "./dist/index.cjs"
34
+ }
35
+ },
36
+ "./generator": {
37
+ "types": "./dist/generator.d.ts",
38
+ "default": "./dist/generator.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "scripts": {
43
+ "build": "rm -rf dist && tsdown",
44
+ "test": "vitest run"
45
+ },
46
+ "devDependencies": {
47
+ "@shipworthy/openapi-generator": "0.0.1",
48
+ "@shipworthy/openapi-transformer": "0.0.1",
49
+ "@shipworthy/typescript-config": "0.0.1",
50
+ "@tanstack/react-query": "^5.104.1",
51
+ "@types/node": "^24.10.3",
52
+ "effect": "^4.0.0",
53
+ "react": "^19.0.0",
54
+ "tsdown": "0.23.0",
55
+ "typescript": "^7.0.2",
56
+ "vitest": "5.0.3"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@shipworthy/openapi-generator": {
60
+ "optional": true
61
+ },
62
+ "@shipworthy/openapi-transformer": {
63
+ "optional": true
64
+ },
65
+ "effect": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "main": "./dist/index.cjs",
70
+ "module": "./dist/index.js",
71
+ "types": "./dist/index.d.cts",
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }