@shipworthy/openapi-react-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,459 @@
1
- # Temporary Holding Version
1
+ # @shipworthy/openapi-react-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) hooks for React, driven by the `paths` type
4
+ generated from an OpenAPI document. You pass your generated `paths` interface once, then call
5
+ hooks with a URL path such as `"/pets/{pet_id}"`. TypeScript infers the request parameters,
6
+ request body, response data, and error body for that operation. No per-operation code is
7
+ generated. Requests go through
8
+ [`@shipworthy/openapi-fetch`](../openapi-fetch), which this package installs for you. You
9
+ generate the `paths` type with `openapi generate typescript` from `@shipworthy/openapi-cli` and
10
+ `@shipworthy/openapi-typescript`.
11
+
12
+ Use this package when you want path-based hooks with a ready-made provider. Use the sibling
13
+ `@shipworthy/openapi-tanstack-query` instead when you want generated, operation-ID-based query
14
+ utilities that work with TanStack's own `useQuery`.
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ bun add @shipworthy/openapi-react-query @tanstack/react-query react
20
+ ```
21
+
22
+ To generate types, also install the CLI, the TypeScript generator, and the generator's peers as dev dependencies:
23
+
24
+ ```sh
25
+ bun add -d @shipworthy/openapi-cli @shipworthy/openapi-typescript effect@^4
26
+ ```
27
+
28
+ | Requirement | Value |
29
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
30
+ | Peer `@tanstack/react-query` | `^5.104.1` |
31
+ | Peer `react` | `^18.0.0 \|\| ^19.0.0` |
32
+ | Dependency | `@shipworthy/openapi-fetch` (installed automatically) |
33
+ | Module format | ESM only (`"type": "module"`). There is no CommonJS build. |
34
+ | Shipped files | Compiled JavaScript and `.d.ts` declarations in `dist/` (`exports["."]` and the `react-native` field point to `./dist/index.js`). |
35
+ | CLI runtime | The `openapi` binary runs on Node.js `>=22.19.0` or Bun (`bunx --bun @shipworthy/openapi-cli`) |
36
+
37
+ No TypeScript or TSX transpilation of `node_modules` is needed. The provider's JSX is compiled
38
+ with the automatic runtime (`react/jsx-runtime`), so there is no `import React`. The package
39
+ declares no Node.js `engines` range. It uses the Fetch API globals supplied by your browser,
40
+ React Native environment, or server runtime. The Node.js requirement above applies to code generation.
41
+
42
+ ## Quick start
43
+
44
+ ### 1. Generate the `paths` type
45
+
46
+ ```sh
47
+ bunx --bun @shipworthy/openapi-cli generate typescript ./openapi.yaml -o ./src/generated/api.ts
48
+ ```
49
+
50
+ The input can be a local JSON or YAML file or a URL. If you omit the input, the CLI reads the
51
+ schema from stdin. If you omit `-o`, it writes to stdout. The output exports `interface paths`,
52
+ along with `components`, `operations`, and related types.
53
+
54
+ ### 2. Wrap your app in `ApiClientProvider`
55
+
56
+ ```tsx
57
+ import { ApiClientProvider } from "@shipworthy/openapi-react-query";
58
+
59
+ export function Root() {
60
+ return (
61
+ <ApiClientProvider baseUrl="https://api.example.com">
62
+ <PetList />
63
+ </ApiClientProvider>
64
+ );
65
+ }
66
+ ```
67
+
68
+ `ApiClientProvider` also renders a TanStack `QueryClientProvider`, so you don't need a second
69
+ provider.
70
+
71
+ ### 3. Bind your `paths` type and call the hooks
72
+
73
+ ```tsx
74
+ import { useApiClientQuery } from "@shipworthy/openapi-react-query";
75
+ import type { paths } from "./generated/api";
76
+
77
+ function PetList() {
78
+ const useQuery = useApiClientQuery<paths>(); // bind the schema once
79
+ const { data, error, isPending } = useQuery("/pets", {
80
+ params: { query: { limit: 20 } },
81
+ });
82
+
83
+ if (isPending) return <p>Loading…</p>;
84
+ if (error) return <p>{error.title}</p>; // typed from the operation's error response
85
+ return (
86
+ <ul>
87
+ {data.data.map((pet) => (
88
+ <li key={pet.id}>{pet.name}</li>
89
+ ))}
90
+ </ul>
91
+ );
92
+ }
93
+ ```
94
+
95
+ The examples below use this `paths` shape, abbreviated from `openapi-typescript` output:
96
+
97
+ ```ts
98
+ interface Pet {
99
+ id: string;
100
+ name: string;
101
+ }
102
+ interface Problem {
103
+ title: string;
104
+ }
105
+
106
+ export interface paths {
107
+ "/pets": {
108
+ get: {
109
+ parameters: { query?: { limit?: number; cursor?: number } };
110
+ responses: {
111
+ 200: { content: { "application/json": { data: Pet[]; next_cursor?: number } } };
112
+ 400: { content: { "application/json": Problem } };
113
+ };
114
+ };
115
+ post: {
116
+ requestBody: { content: { "application/json": { name: string } } };
117
+ responses: {
118
+ 201: { content: { "application/json": Pet } };
119
+ 422: { content: { "application/json": Problem } };
120
+ };
121
+ };
122
+ };
123
+ "/pets/{pet_id}": {
124
+ get: {
125
+ parameters: { path: { pet_id: string } };
126
+ responses: {
127
+ 200: { content: { "application/json": Pet } };
128
+ 404: { content: { "application/json": Problem } };
129
+ };
130
+ };
131
+ delete: {
132
+ parameters: { path: { pet_id: string } };
133
+ responses: { 204: { content?: never } };
134
+ };
135
+ };
136
+ }
137
+ ```
138
+
139
+ ## API
140
+
141
+ The package has a single entry point: `@shipworthy/openapi-react-query`.
142
+
143
+ | Export | Kind |
144
+ | --------------------------- | -------------------------- |
145
+ | `ApiClientProvider` | React component |
146
+ | `useApiClient` | Hook |
147
+ | `useApiClientQuery` | Hook factory |
148
+ | `useApiClientSuspenseQuery` | Hook factory |
149
+ | `useApiClientQueryOptions` | Hook factory |
150
+ | `useApiClientInfiniteQuery` | Hook factory |
151
+ | `useApiClientMutation` | Hook factory |
152
+ | `createApiClient` | Function (no React needed) |
153
+ | `ApiClient` | Type |
154
+ | `QueryKey` | Type |
155
+
156
+ ### Calling the hook factories
157
+
158
+ Each `useApiClient*` export takes your `paths` type as a type argument and returns a hook
159
+ function:
160
+
161
+ ```ts
162
+ const useQuery = useApiClientQuery<paths>();
163
+ ```
164
+
165
+ Calling the factory runs no hooks. It returns the same function every time. The returned
166
+ function is a React hook: it reads the provider context and calls TanStack, so it must follow
167
+ the Rules of Hooks. If you don't pass `paths`, it defaults to `{}`, and no path type-checks.
168
+
169
+ The returned hooks take arguments in this order:
170
+
171
+ ```
172
+ hook(path, [method], init, options, queryClient)
173
+ ```
174
+
175
+ | Argument | Description |
176
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `path` | A key of `paths`, such as `"/pets/{pet_id}"`. Only paths that have the selected method are accepted. |
178
+ | `method` | Optional lowercase HTTP method: `"get" \| "put" \| "post" \| "delete" \| "options" \| "head" \| "patch" \| "trace"`. Query hooks default to `"get"`; the mutation hook defaults to `"post"`. The hook treats a string second argument as the method. |
179
+ | `init` | `openapi-fetch` request options: `params.path`, `params.query`, `params.header`, `params.cookie`, `body`, and similar. It is required when the operation has required parameters or a required body; otherwise it is optional. You can pass extra keys, which go to `openapi-fetch` unchanged. The mutation hook takes no `init` here; pass it to `mutate()` instead. |
180
+ | `options` | TanStack options for the underlying hook, minus `queryKey`/`queryFn` (or `mutationKey`/`mutationFn`). `select` is typed, and the result's `data` type follows its return type. |
181
+ | `queryClient` | Optional `QueryClient`. It is passed through to TanStack, and overrides the one from context. |
182
+
183
+ How results behave:
184
+
185
+ - **`data`** is the operation's success response body (the 2XX JSON).
186
+ - **`error`** is the parsed error response body (the non-2XX JSON) that `openapi-fetch` returns.
187
+ The hooks throw this body itself, not an `Error` instance. Network failures from `fetch`
188
+ propagate as they are.
189
+ - **Empty responses:** in the query and suspense hooks, a `204` response or a response with
190
+ `Content-Length: 0` resolves to `null` instead of `undefined`.
191
+ - **Cancellation:** query hooks pass TanStack's `AbortSignal` to the request as `signal`.
192
+
193
+ ### `ApiClientProvider`
194
+
195
+ ```ts
196
+ function ApiClientProvider(props: {
197
+ baseUrl: string;
198
+ queryClient?: QueryClient;
199
+ children?: React.ReactNode;
200
+ }): JSX.Element;
201
+ ```
202
+
203
+ The provider creates an `openapi-fetch` client for `baseUrl` and puts it in context. It also
204
+ renders `QueryClientProvider`.
205
+
206
+ If you don't pass `queryClient`, the provider uses a default `QueryClient` with
207
+ `defaultOptions.queries.staleTime = 60_000`. In the browser, one module-level instance is
208
+ created and reused. On the server (TanStack's `isServer`), it creates a new instance on every
209
+ render.
210
+
211
+ The provider takes no headers or auth token. See [Limitations](#limitations-and-caveats).
212
+
213
+ ```tsx
214
+ import { QueryClient } from "@tanstack/react-query";
215
+ import { ApiClientProvider } from "@shipworthy/openapi-react-query";
216
+
217
+ const queryClient = new QueryClient();
218
+
219
+ <ApiClientProvider baseUrl={import.meta.env.VITE_API_URL} queryClient={queryClient}>
220
+ <App />
221
+ </ApiClientProvider>;
222
+ ```
223
+
224
+ ### `useApiClient<Paths>()`
225
+
226
+ ```ts
227
+ function useApiClient<Paths extends {} = {}>(): Client<Paths>; // Client from @shipworthy/openapi-fetch
228
+ ```
229
+
230
+ Returns the provider's `openapi-fetch` client, with methods `GET`, `POST`, `PUT`, `PATCH`,
231
+ `DELETE`, and so on. Use it for one-off imperative calls inside components.
232
+
233
+ If no `ApiClientProvider` is above the component, it throws
234
+ `Error("useApiClient must be used within ApiClientProvider")`. Every hook in this package
235
+ calls `useApiClient`, so they all throw this error outside the provider.
236
+
237
+ ```ts
238
+ const client = useApiClient<paths>();
239
+ const { data, error, response } = await client.GET("/pets/{pet_id}", {
240
+ params: { path: { pet_id: "p1" } },
241
+ });
242
+ ```
243
+
244
+ ### `useApiClientQuery<Paths>()`
245
+
246
+ Returns a hook that wraps TanStack `useQuery` and returns `UseQueryResult<TData, TError>`.
247
+
248
+ ```ts
249
+ const useQuery = useApiClientQuery<paths>();
250
+
251
+ // Path params are required, so the call needs init.
252
+ const pet = useQuery(
253
+ "/pets/{pet_id}",
254
+ { params: { path: { pet_id: "p1" } } },
255
+ { staleTime: 5_000 },
256
+ );
257
+
258
+ // init is optional here, so pass undefined to reach options. select narrows the data type to string[].
259
+ const names = useQuery("/pets", undefined, { select: (res) => res.data.map((p) => p.name) });
260
+
261
+ // Explicit method
262
+ const same = useQuery("/pets/{pet_id}", "get", { params: { path: { pet_id: "p1" } } });
263
+ ```
264
+
265
+ The query key is `[method, path]`, or `[method, path, init]` when you pass `init`. For example:
266
+ `["get", "/pets", { params: { query: { limit: 20 } } }]`.
267
+
268
+ ### `useApiClientSuspenseQuery<Paths>()`
269
+
270
+ Same arguments as `useApiClientQuery`. It wraps TanStack `useSuspenseQuery`, so `data` is always
271
+ defined. Render the component inside a `<Suspense>` boundary, and catch errors with an error
272
+ boundary.
273
+
274
+ ```tsx
275
+ function PetDetail({ id }: { id: string }) {
276
+ const useSuspenseQuery = useApiClientSuspenseQuery<paths>();
277
+ const { data } = useSuspenseQuery("/pets/{pet_id}", { params: { path: { pet_id: id } } });
278
+ return <h1>{data.name}</h1>;
279
+ }
280
+ ```
281
+
282
+ ### `useApiClientQueryOptions<Paths>()`
283
+
284
+ Returns a hook that builds a TanStack options object (`{ queryKey, queryFn, ...options }`)
285
+ instead of running a query. It takes the same `path`, `[method]`, `init`, and `options`
286
+ arguments, but no `queryClient`. The returned `queryFn` is never `skipToken`.
287
+
288
+ Use the result with TanStack APIs: `useQuery`, `useQueries`, `queryClient.prefetchQuery`,
289
+ `fetchQuery`, `ensureQueryData`, and so on. It reads the client from context, so you must call
290
+ it inside a component under `ApiClientProvider`.
291
+
292
+ ```tsx
293
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
294
+
295
+ const queryOptions = useApiClientQueryOptions<paths>();
296
+ const options = queryOptions("/pets", { params: { query: { limit: 20 } } });
297
+
298
+ const result = useQuery(options); // result.data is typed
299
+ const queryClient = useQueryClient();
300
+ void queryClient.prefetchQuery(options);
301
+ ```
302
+
303
+ ### `useApiClientInfiniteQuery<Paths>()`
304
+
305
+ Wraps TanStack `useInfiniteQuery` for cursor- or offset-paginated endpoints. Its arguments
306
+ differ from the other query hooks: `init` and `options` are required.
307
+
308
+ ```
309
+ hook(path, [method], init, options, queryClient?)
310
+ ```
311
+
312
+ - `options` takes TanStack's infinite-query options, so `initialPageParam` and
313
+ `getNextPageParam` are required. It also takes `pageParamName?: string` (default
314
+ `"cursor"`).
315
+ - On each page request, the hook sets `params.query[pageParamName]` to the current page
316
+ param, merged with any query params in `init`. If the page param is `undefined`, the hook
317
+ sends `0`.
318
+ - `data` is TanStack's `InfiniteData<TData>`, an object with `pages` and `pageParams`.
319
+ - The query key is the same as for `useApiClientQuery`. It does not include the page param.
320
+ - Unlike the other query hooks, it applies no `204` → `null` handling.
321
+
322
+ ```ts
323
+ const useInfiniteQuery = useApiClientInfiniteQuery<paths>();
324
+ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
325
+ "/pets",
326
+ { params: { query: { limit: 20 } } },
327
+ {
328
+ initialPageParam: 0,
329
+ getNextPageParam: (lastPage) => lastPage.next_cursor, // undefined ends pagination
330
+ pageParamName: "cursor", // sent as ?cursor=<pageParam>
331
+ },
332
+ );
333
+ const pets = data?.pages.flatMap((page) => page.data);
334
+ ```
335
+
336
+ ### `useApiClientMutation<Paths>()`
337
+
338
+ Wraps TanStack `useMutation` and returns `UseMutationResult<TData, TError, TInit>`.
339
+
340
+ ```
341
+ hook(path, [method], options?, queryClient?)
342
+ ```
343
+
344
+ - The method defaults to `"post"`.
345
+ - You pass the request `init` (body, params) to `mutate()` or `mutateAsync()`, not to the hook.
346
+ - The mutation key is `[method, path]`.
347
+ - Options are TanStack mutation options minus `mutationKey` and `mutationFn`.
348
+
349
+ ```ts
350
+ import { useQueryClient } from "@tanstack/react-query";
351
+
352
+ const useMutation = useApiClientMutation<paths>();
353
+ const queryClient = useQueryClient();
354
+
355
+ const createPet = useMutation("/pets", {
356
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["get", "/pets"] }),
357
+ });
358
+ createPet.mutate({ body: { name: "Rex" } });
359
+
360
+ const deletePet = useMutation("/pets/{pet_id}", "delete");
361
+ deletePet.mutate({ params: { path: { pet_id: "p1" } } });
362
+ ```
363
+
364
+ ### `createApiClient<Paths>(baseUrl, authToken?)`
365
+
366
+ ```ts
367
+ function createApiClient<Paths extends {}>(baseUrl: string, authToken?: string): Client<Paths>;
368
+ ```
369
+
370
+ Creates a standalone `openapi-fetch` client. Use it outside React, for example in scripts,
371
+ server code, or loaders.
372
+
373
+ - If you pass `authToken`, every request sends `Authorization: Bearer <authToken>`.
374
+ - Methods return `{ data, error, response }` and do not throw on HTTP errors.
375
+
376
+ This client is separate from the one `ApiClientProvider` creates, and the hooks don't use it.
377
+
378
+ ```ts
379
+ import { createApiClient } from "@shipworthy/openapi-react-query";
380
+ import type { paths } from "./generated/api";
381
+
382
+ const api = createApiClient<paths>("https://api.example.com", process.env.API_TOKEN);
383
+ const { data, error, response } = await api.GET("/pets", { params: { query: { limit: 10 } } });
384
+ if (error) console.error(response.status, error.title);
385
+ ```
386
+
387
+ ### Types
388
+
389
+ - **`ApiClient`**: `ReturnType<typeof createApiClient>`, the client type with its `Paths`
390
+ parameter erased. For a client bound to your schema, annotate with
391
+ `ReturnType<typeof createApiClient<paths>>`.
392
+ - **`QueryKey<Paths, Method, Path, Init?>`**: the key that the query hooks build.
393
+ - It is `readonly [Method, Path]` when `Init` is `undefined`.
394
+ - Otherwise it is `readonly [Method, Path, Init]`.
395
+
396
+ ```ts
397
+ import type { QueryKey } from "@shipworthy/openapi-react-query";
398
+ type PetsKey = QueryKey<paths, "get", "/pets">;
399
+ const key: PetsKey = ["get", "/pets", { params: { query: { limit: 20 } } }];
400
+ ```
401
+
402
+ ## Cache keys and invalidation
403
+
404
+ | Hook | Key |
405
+ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
406
+ | Queries (`useApiClientQuery`, `useApiClientSuspenseQuery`, `useApiClientQueryOptions`, `useApiClientInfiniteQuery`) | `[method, path]` or `[method, path, init]` |
407
+ | Mutations | `[method, path]` |
408
+
409
+ The method is lowercase, and the path is the OpenAPI template (`"/pets/{pet_id}"`), not the
410
+ expanded URL. TanStack matches keys by prefix, so
411
+ `queryClient.invalidateQueries({ queryKey: ["get", "/pets"] })` invalidates every `GET /pets`
412
+ query, whatever its `init`.
413
+
414
+ ## Limitations and caveats
415
+
416
+ - **No auth or headers from the provider.** `ApiClientProvider` accepts only `baseUrl` and
417
+ `queryClient`. To send headers, pass them per request in `init` (`params.header`, or extra
418
+ `openapi-fetch` options). `createApiClient`'s `authToken` affects only that standalone client.
419
+ - **The provider creates a new client on every render** of `ApiClientProvider`. Place it high
420
+ in the tree, where it rarely re-renders.
421
+ - **Thrown errors are response bodies,** not `Error` objects, so `error instanceof Error` is
422
+ `false` for HTTP errors.
423
+ - **OpenAPI support** depends on `@shipworthy/openapi-typescript`, which generates the `paths`
424
+ type. That package describes itself as converting OpenAPI 3.0 and 3.1 schemas.
425
+
426
+ ## Troubleshooting
427
+
428
+ | Symptom | Cause / fix |
429
+ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
430
+ | `useApiClient must be used within ApiClientProvider` | A hook was called outside `ApiClientProvider`. Wrap the tree. |
431
+ | Every path is a type error | You called the factory without `paths` (`useApiClientQuery()`). Use `useApiClientQuery<paths>()`. |
432
+ | A path is not accepted | That path has no operation for the method. Pass the method explicitly, e.g. `useQuery("/x", "post", …)`. |
433
+ | Argument-count error when you omit `init` | The operation has required path, query, or header params, or a required body, so `init` is required. |
434
+ | Infinite query never sends a page | The API expects a query param other than `cursor`. Set `pageParamName`. |
435
+
436
+ ## Key facts (for AI agents)
437
+
438
+ - Import everything from `@shipworthy/openapi-react-query`. There are no subpath exports and
439
+ no CLI in this package.
440
+ - Generate the `paths` type with
441
+ `openapi generate typescript <schema> -o <file>.ts` (`@shipworthy/openapi-cli` +
442
+ `@shipworthy/openapi-typescript`). Import it as `import type { paths } from "<file>"`.
443
+ - Mount `<ApiClientProvider baseUrl="…">` once. It already includes `QueryClientProvider`.
444
+ - The hook factories are curried. Call `useApiClientQuery<paths>()` first, and then call the
445
+ returned hook as `hook(path, method?, init, options?, queryClient?)`. Never call
446
+ `useApiClientQuery<paths>("/path", …)`.
447
+ - Methods are lowercase strings. Queries default to `"get"`; mutations default to `"post"`.
448
+ - Mutation `init` goes to `mutate(init)`, not to the hook.
449
+ - Infinite queries need `initialPageParam` and `getNextPageParam`. The page param is sent as
450
+ the `pageParamName` query param (default `cursor`).
451
+ - Query keys are `[method, path, init?]`; mutation keys are `[method, path]`.
452
+ - Hooks throw the typed error body. Query and suspense hooks resolve empty `204` responses as
453
+ `null`.
454
+ - `createApiClient(baseUrl, authToken?)` is for non-React use. The hooks don't use it.
455
+ - Peers: `react` `^18 || ^19`, `@tanstack/react-query` `^5.104.1`. ESM-only compiled JavaScript with `.d.ts` declarations.
456
+
457
+ ## License
458
+
459
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,2 @@
1
+ export declare function createApiClient<Paths extends {}>(baseUrl: string, authToken?: string): import("@shipworthy/openapi-fetch").Client<Paths, `${string}/${string}`>;
2
+ export type ApiClient = ReturnType<typeof createApiClient>;
package/dist/client.js ADDED
@@ -0,0 +1,7 @@
1
+ import createClient from "@shipworthy/openapi-fetch";
2
+ export function createApiClient(baseUrl, authToken) {
3
+ return createClient({
4
+ baseUrl,
5
+ headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined,
6
+ });
7
+ }
@@ -0,0 +1,13 @@
1
+ import { type InfiniteData, type QueryClient, type UseInfiniteQueryOptions, type UseInfiniteQueryResult } from "@tanstack/react-query";
2
+ import type { FetchResponse, HttpMethod, MaybeOptionalInit, MediaType, PathsWithMethod } from "@shipworthy/openapi-fetch";
3
+ import { type InferSelectReturnType, type InitWithUnknowns, type QueryKey } from "../utils.ts";
4
+ interface ApiClientInfiniteQuery<Paths extends Record<string, any>> {
5
+ <Path extends PathsWithMethod<Paths, "get">, Init extends MaybeOptionalInit<Paths[Path], "get">, Response extends Required<FetchResponse<Paths[Path]["get"], Init, MediaType>>, Options extends Omit<UseInfiniteQueryOptions<Response["data"], Response["error"], InferSelectReturnType<InfiniteData<Response["data"]>, Options["select"]>, QueryKey<Paths, "get", Path>, unknown>, "queryKey" | "queryFn"> & {
6
+ pageParamName?: string;
7
+ }>(path: Path, init: InitWithUnknowns<Init>, options: Options, queryClient?: QueryClient): UseInfiniteQueryResult<InferSelectReturnType<InfiniteData<Response["data"]>, Options["select"]>, Response["error"]>;
8
+ <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>, Response extends Required<FetchResponse<Paths[Path][Method] & Record<string | number, any>, Init, MediaType>>, Options extends Omit<UseInfiniteQueryOptions<Response["data"], Response["error"], InferSelectReturnType<InfiniteData<Response["data"]>, Options["select"]>, QueryKey<Paths, Method, Path>, unknown>, "queryKey" | "queryFn"> & {
9
+ pageParamName?: string;
10
+ }>(path: Path, method: Method, init: InitWithUnknowns<Init>, options: Options, queryClient?: QueryClient): UseInfiniteQueryResult<InferSelectReturnType<InfiniteData<Response["data"]>, Options["select"]>, Response["error"]>;
11
+ }
12
+ export declare function useApiClientInfiniteQuery<Paths extends Record<string, any> = {}>(): ApiClientInfiniteQuery<Paths>;
13
+ export {};
@@ -0,0 +1,36 @@
1
+ import { useInfiniteQuery as useTanstackInfiniteQuery, } from "@tanstack/react-query";
2
+ import { useApiClient } from "../provider.js";
3
+ import { buildQueryKey, parseArgs, } from "../utils.js";
4
+ export function useApiClientInfiniteQuery() {
5
+ return useInfiniteQuery;
6
+ }
7
+ function useInfiniteQuery(path, ...args) {
8
+ const client = useApiClient();
9
+ const { method, rest } = parseArgs(args, "get");
10
+ const [init, options, queryClient] = rest;
11
+ const { pageParamName = "cursor", ...restOptions } = options;
12
+ const queryKey = buildQueryKey(method, path, init);
13
+ return useTanstackInfiniteQuery({
14
+ queryKey,
15
+ queryFn: async ({ pageParam = 0, signal }) => {
16
+ const mth = method.toUpperCase();
17
+ const fn = client[mth];
18
+ const mergedInit = {
19
+ ...init,
20
+ signal,
21
+ params: {
22
+ ...(init?.params ?? undefined),
23
+ query: {
24
+ ...(init?.params?.query ?? undefined),
25
+ [pageParamName]: pageParam,
26
+ },
27
+ },
28
+ };
29
+ const { data, error } = await fn(path, mergedInit);
30
+ if (error)
31
+ throw error;
32
+ return data;
33
+ },
34
+ ...restOptions,
35
+ }, queryClient);
36
+ }
@@ -0,0 +1,8 @@
1
+ import { type QueryClient, type UseMutationOptions, type UseMutationResult } from "@tanstack/react-query";
2
+ import type { FetchResponse, HttpMethod, MaybeOptionalInit, MediaType, PathsWithMethod } from "@shipworthy/openapi-fetch";
3
+ interface ApiClientMutation<Paths extends Record<string, any>> {
4
+ <Path extends PathsWithMethod<Paths, "post">, Init extends MaybeOptionalInit<Paths[Path], "post">, Response extends Required<FetchResponse<Paths[Path]["post"], Init, MediaType>>, Options extends Omit<UseMutationOptions<Response["data"], Response["error"], Init>, "mutationKey" | "mutationFn">>(path: Path, options?: Options, queryClient?: QueryClient): UseMutationResult<Response["data"], Response["error"], Init>;
5
+ <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>, Response extends Required<FetchResponse<Paths[Path][Method] & Record<string | number, any>, Init, MediaType>>, Options extends Omit<UseMutationOptions<Response["data"], Response["error"], Init>, "mutationKey" | "mutationFn">>(path: Path, method: Method, options?: Options, queryClient?: QueryClient): UseMutationResult<Response["data"], Response["error"], Init>;
6
+ }
7
+ export declare function useApiClientMutation<Paths extends Record<string, any> = {}>(): ApiClientMutation<Paths>;
8
+ export {};
@@ -0,0 +1,23 @@
1
+ import { useMutation as useTanstackMutation, } from "@tanstack/react-query";
2
+ import { useApiClient } from "../provider.js";
3
+ import { parseArgs } from "../utils.js";
4
+ export function useApiClientMutation() {
5
+ return useMutation;
6
+ }
7
+ function useMutation(path, ...args) {
8
+ const client = useApiClient();
9
+ const { method, rest } = parseArgs(args, "post");
10
+ const [options, queryClient] = rest;
11
+ return useTanstackMutation({
12
+ mutationKey: [method, path],
13
+ mutationFn: async (init) => {
14
+ const mth = method.toUpperCase();
15
+ const fn = client[mth];
16
+ const { data, error } = await fn(path, init);
17
+ if (error)
18
+ throw error;
19
+ return data;
20
+ },
21
+ ...(options ?? undefined),
22
+ }, queryClient);
23
+ }
@@ -0,0 +1,20 @@
1
+ import { type QueryClient, type SkipToken, type UseQueryOptions, type UseQueryResult } from "@tanstack/react-query";
2
+ import type { FetchResponse, HttpMethod, MaybeOptionalInit, MediaType, PathsWithMethod, RequiredKeysOf } from "@shipworthy/openapi-fetch";
3
+ import { type InferSelectReturnType, type InitWithUnknowns, type QueryKey } from "../utils.ts";
4
+ interface ApiClientQueryOptions<Paths extends Record<string, any>> {
5
+ <Path extends PathsWithMethod<Paths, "get">, Init extends MaybeOptionalInit<Paths[Path], "get">, Response extends Required<FetchResponse<Paths[Path]["get"], Init, MediaType>>, Options extends Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, "get", Path>>, "queryKey" | "queryFn">>(path: Path, ...[init, options]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?] : [InitWithUnknowns<Init>, Options?]): NoInfer<Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, "get", Path>>, "queryFn"> & {
6
+ queryFn: Exclude<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, "get", Path>>["queryFn"], SkipToken | undefined>;
7
+ }>;
8
+ <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>, Response extends Required<FetchResponse<Paths[Path][Method] & Record<string | number, any>, Init, MediaType>>, Options extends Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, Method, Path>>, "queryKey" | "queryFn">>(path: Path, method: Method, ...[init, options]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?] : [InitWithUnknowns<Init>, Options?]): NoInfer<Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, Method, Path>>, "queryFn"> & {
9
+ queryFn: Exclude<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, Method, Path>>["queryFn"], SkipToken | undefined>;
10
+ }>;
11
+ }
12
+ interface ApiClientQuery<Paths extends Record<string, any>> {
13
+ <Path extends PathsWithMethod<Paths, "get">, Init extends MaybeOptionalInit<Paths[Path], "get">, Response extends Required<FetchResponse<Paths[Path]["get"], Init, MediaType>>, Options extends Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, "get", Path>>, "queryKey" | "queryFn">>(path: Path, ...[init, options, queryClient]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?, QueryClient?] : [InitWithUnknowns<Init>, Options?, QueryClient?]): UseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
14
+ <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>, Response extends Required<FetchResponse<Paths[Path][Method] & Record<string | number, any>, Init, MediaType>>, Options extends Omit<UseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, Method, Path>>, "queryKey" | "queryFn">>(path: Path, method: Method, ...[init, options, queryClient]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?, QueryClient?] : [InitWithUnknowns<Init>, Options?, QueryClient?]): UseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
15
+ }
16
+ /** Bind a schema first, then call the returned hook to infer each endpoint. */
17
+ export declare function useApiClientQuery<Paths extends Record<string, any> = {}>(): ApiClientQuery<Paths>;
18
+ /** Bind a schema first, then call the returned hook to build query options. */
19
+ export declare function useApiClientQueryOptions<Paths extends Record<string, any> = {}>(): ApiClientQueryOptions<Paths>;
20
+ export {};
@@ -0,0 +1,31 @@
1
+ import { useQuery as useTanstackQuery, } from "@tanstack/react-query";
2
+ import { useApiClient } from "../provider.js";
3
+ import { buildQueryFn, buildQueryKey, parseArgs, } from "../utils.js";
4
+ /** Bind a schema first, then call the returned hook to infer each endpoint. */
5
+ export function useApiClientQuery() {
6
+ return useQuery;
7
+ }
8
+ /** Bind a schema first, then call the returned hook to build query options. */
9
+ export function useApiClientQueryOptions() {
10
+ return useQueryOptions;
11
+ }
12
+ function useQueryOptions(path, ...args) {
13
+ const client = useApiClient();
14
+ const { method, rest } = parseArgs(args, "get");
15
+ const [init, options] = rest;
16
+ return {
17
+ queryKey: buildQueryKey(method, path, init),
18
+ queryFn: buildQueryFn(client, method, path, init),
19
+ ...(options ?? undefined),
20
+ };
21
+ }
22
+ function useQuery(path, ...args) {
23
+ const client = useApiClient();
24
+ const { method, rest } = parseArgs(args, "get");
25
+ const [init, options, queryClient] = rest;
26
+ return useTanstackQuery({
27
+ queryKey: buildQueryKey(method, path, init),
28
+ queryFn: buildQueryFn(client, method, path, init),
29
+ ...(options ?? undefined),
30
+ }, queryClient);
31
+ }
@@ -0,0 +1,10 @@
1
+ import { type QueryClient, type UseSuspenseQueryOptions, type UseSuspenseQueryResult } from "@tanstack/react-query";
2
+ import type { FetchResponse, HttpMethod, MaybeOptionalInit, MediaType, PathsWithMethod, RequiredKeysOf } from "@shipworthy/openapi-fetch";
3
+ import { type InferSelectReturnType, type InitWithUnknowns, type QueryKey } from "../utils.ts";
4
+ interface ApiClientSuspenseQuery<Paths extends Record<string, any>> {
5
+ <Path extends PathsWithMethod<Paths, "get">, Init extends MaybeOptionalInit<Paths[Path], "get">, Response extends Required<FetchResponse<Paths[Path]["get"], Init, MediaType>>, Options extends Omit<UseSuspenseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, "get", Path>>, "queryKey" | "queryFn">>(path: Path, ...[init, options, queryClient]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?, QueryClient?] : [InitWithUnknowns<Init>, Options?, QueryClient?]): UseSuspenseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
6
+ /** Explicit method overload */
7
+ <Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init extends MaybeOptionalInit<Paths[Path], Method>, Response extends Required<FetchResponse<Paths[Path][Method] & Record<string | number, any>, Init, MediaType>>, Options extends Omit<UseSuspenseQueryOptions<Response["data"], Response["error"], InferSelectReturnType<Response["data"], Options["select"]>, QueryKey<Paths, Method, Path>>, "queryKey" | "queryFn">>(path: Path, method: Method, ...[init, options, queryClient]: RequiredKeysOf<Init> extends never ? [InitWithUnknowns<Init>?, Options?, QueryClient?] : [InitWithUnknowns<Init>, Options?, QueryClient?]): UseSuspenseQueryResult<InferSelectReturnType<Response["data"], Options["select"]>, Response["error"]>;
8
+ }
9
+ export declare function useApiClientSuspenseQuery<Paths extends Record<string, any> = {}>(): ApiClientSuspenseQuery<Paths>;
10
+ export {};
@@ -0,0 +1,16 @@
1
+ import { useSuspenseQuery as useTanstackSuspenseQuery, } from "@tanstack/react-query";
2
+ import { useApiClient } from "../provider.js";
3
+ import { buildQueryFn, buildQueryKey, parseArgs, } from "../utils.js";
4
+ export function useApiClientSuspenseQuery() {
5
+ return useSuspenseQuery;
6
+ }
7
+ function useSuspenseQuery(path, ...args) {
8
+ const client = useApiClient();
9
+ const { method, rest } = parseArgs(args, "get");
10
+ const [init, options, queryClient] = rest;
11
+ return useTanstackSuspenseQuery({
12
+ queryKey: buildQueryKey(method, path, init),
13
+ queryFn: buildQueryFn(client, method, path, init),
14
+ ...(options ?? undefined),
15
+ }, queryClient);
16
+ }
@@ -0,0 +1,8 @@
1
+ export { createApiClient } from "./client.ts";
2
+ export type { ApiClient } from "./client.ts";
3
+ export { ApiClientProvider, useApiClient } from "./provider.tsx";
4
+ export { useApiClientQueryOptions, useApiClientQuery } from "./hooks/use-query.ts";
5
+ export { useApiClientSuspenseQuery } from "./hooks/use-suspense-query.ts";
6
+ export { useApiClientInfiniteQuery } from "./hooks/use-infinite-query.ts";
7
+ export { useApiClientMutation } from "./hooks/use-mutation.ts";
8
+ export type { QueryKey } from "./utils.ts";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createApiClient } from "./client.js";
2
+ export { ApiClientProvider, useApiClient } from "./provider.js";
3
+ export { useApiClientQueryOptions, useApiClientQuery } from "./hooks/use-query.js";
4
+ export { useApiClientSuspenseQuery } from "./hooks/use-suspense-query.js";
5
+ export { useApiClientInfiniteQuery } from "./hooks/use-infinite-query.js";
6
+ export { useApiClientMutation } from "./hooks/use-mutation.js";
@@ -0,0 +1,8 @@
1
+ import { type PropsWithChildren } from "react";
2
+ import { QueryClient } from "@tanstack/react-query";
3
+ import { type Client } from "@shipworthy/openapi-fetch";
4
+ export declare function useApiClient<Paths extends {} = {}>(): Client<Paths>;
5
+ export declare function ApiClientProvider({ children, baseUrl, queryClient, }: PropsWithChildren<{
6
+ baseUrl: string;
7
+ queryClient?: QueryClient;
8
+ }>): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ import { isServer, QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import createClient from "@shipworthy/openapi-fetch";
5
+ const ApiClientContext = createContext(null);
6
+ export function useApiClient() {
7
+ const client = useContext(ApiClientContext);
8
+ if (!client)
9
+ throw new Error("useApiClient must be used within ApiClientProvider");
10
+ return client;
11
+ }
12
+ function makeQueryClient() {
13
+ return new QueryClient({
14
+ defaultOptions: { queries: { staleTime: 60 * 1000 } },
15
+ });
16
+ }
17
+ let browserQueryClient;
18
+ function getQueryClient() {
19
+ if (isServer)
20
+ return makeQueryClient();
21
+ if (!browserQueryClient)
22
+ browserQueryClient = makeQueryClient();
23
+ return browserQueryClient;
24
+ }
25
+ export function ApiClientProvider({ children, baseUrl, queryClient = getQueryClient(), }) {
26
+ const client = createClient({ baseUrl });
27
+ return (_jsx(ApiClientContext.Provider, { value: client, children: _jsx(QueryClientProvider, { client: queryClient, children: children }) }));
28
+ }
@@ -0,0 +1,14 @@
1
+ import type { Client, HttpMethod, MaybeOptionalInit, PathsWithMethod } from "@shipworthy/openapi-fetch";
2
+ export type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer R ? R : TData;
3
+ export type InitWithUnknowns<Init> = Init & {
4
+ [key: string]: unknown;
5
+ };
6
+ export type QueryKey<Paths extends Record<string, any>, Method extends HttpMethod, Path extends PathsWithMethod<Paths, Method>, Init = MaybeOptionalInit<Paths[Path], Method>> = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init];
7
+ export declare function parseArgs(args: unknown[], defaultMethod: HttpMethod): {
8
+ method: HttpMethod;
9
+ rest: unknown[];
10
+ };
11
+ export declare function buildQueryFn(client: Client<{}>, method: HttpMethod, path: string, init: unknown): ({ signal }: {
12
+ signal: AbortSignal;
13
+ }) => Promise<any>;
14
+ export declare function buildQueryKey(method: HttpMethod, path: string, init: unknown): readonly [HttpMethod, string] | readonly [HttpMethod, string, {} | null];
package/dist/utils.js ADDED
@@ -0,0 +1,26 @@
1
+ export function parseArgs(args, defaultMethod) {
2
+ if (typeof args[0] === "string") {
3
+ const [method, ...rest] = args;
4
+ return { method: method, rest };
5
+ }
6
+ return { method: defaultMethod, rest: args };
7
+ }
8
+ export function buildQueryFn(client, method, path, init) {
9
+ return async ({ signal }) => {
10
+ const mth = method.toUpperCase();
11
+ const fn = client[mth];
12
+ const { data, error, response } = await fn(path, {
13
+ signal,
14
+ ...(init ?? undefined),
15
+ });
16
+ if (error)
17
+ throw error;
18
+ if (response.status === 204 || response.headers.get("Content-Length") === "0") {
19
+ return data ?? null;
20
+ }
21
+ return data;
22
+ };
23
+ }
24
+ export function buildQueryKey(method, path, init) {
25
+ return init === undefined ? [method, path] : [method, path, init];
26
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@shipworthy/openapi-react-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
+ "@tanstack/react-query": "^5.104.1",
12
+ "react": "^18.0.0 || ^19.0.0"
13
+ },
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ShipWorthyAI/openapi.git",
18
+ "directory": "packages/openapi-react-query"
19
+ },
20
+ "type": "module",
21
+ "react-native": "./dist/index.js",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "scripts": {
30
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
31
+ },
32
+ "devDependencies": {
33
+ "@shipworthy/typescript-config": "0.0.1",
34
+ "@tanstack/react-query": "^5.104.1",
35
+ "@types/node": "^24.10.3",
36
+ "@types/react": "^19.0.0",
37
+ "react": "^19.0.0",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "main": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }