@shipworthy/openapi-fetch 0.0.0-stage → 0.0.2

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,576 @@
1
- # Temporary Holding Version
1
+ # @shipworthy/openapi-fetch
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
+ A small, type-safe `fetch` wrapper for OpenAPI 3.x APIs. You give it the `paths` type that `@shipworthy/openapi-typescript` generates from your schema. In return, every call checks the URL, method, path/query/header parameters, request body, and response types at compile time. At runtime it is a thin layer over the platform `fetch`: it builds the URL, serializes parameters and the body, runs middleware, and parses the response into a `{ data, error, response }` result. It has no runtime dependencies and does no runtime validation. Use it when you want a typed HTTP client without generating per-operation SDK code. `@shipworthy/openapi-tanstack-query` is built on this client; use that package if you want TanStack Query hooks instead. The API is derived from [`openapi-fetch`](https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch) in the openapi-typescript project.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bun add @shipworthy/openapi-fetch
9
+ ```
10
+
11
+ To generate the `paths` type, install the CLI and its TypeScript generator as dev dependencies:
12
+
13
+ ```sh
14
+ bun add -d @shipworthy/openapi-cli @shipworthy/openapi-typescript
15
+ ```
16
+
17
+ These packages are build-time tooling; the fetch client has no runtime or peer dependencies.
18
+
19
+ Requirements:
20
+
21
+ - **No dependencies or peer dependencies.**
22
+ - **ESM and CommonJS.** Published packages contain compiled JavaScript in both formats with matching `.d.ts` and `.d.cts` declarations in `dist/`; no TypeScript transpilation of `node_modules` is needed. Declarations type-check with TypeScript 5.0 and later.
23
+ - **No Node.js engine requirement for the client.** The code-generation CLI needs Node.js `>=22.19.0`, or Bun with `bunx --bun`.
24
+ - **Fetch API globals.** The client uses `fetch`, `Request`, `Response`, `Headers`, `FormData`, and `URLSearchParams` from `globalThis`, so it runs in browsers, Bun, Deno, and Node.js 18+. To use a different implementation, pass `fetch` and/or `Request` to `createClient`.
25
+ - **TypeScript** with `strict` (or at least `strictNullChecks`) is recommended. Required/optional inference for `params` and `body` depends on it.
26
+
27
+ ## Quick start
28
+
29
+ ### 1. Generate types
30
+
31
+ ```sh
32
+ bunx --bun @shipworthy/openapi-cli generate typescript ./openapi.yaml -o ./src/api/schema.ts
33
+ ```
34
+
35
+ The generated file exports `paths`, `components`, and `operations` interfaces. `openapi-fetch` only needs `paths`.
36
+
37
+ ### 2. Create a client and call it
38
+
39
+ ```ts
40
+ import createClient from "@shipworthy/openapi-fetch";
41
+ import type { paths } from "./api/schema";
42
+
43
+ const client = createClient<paths>({ baseUrl: "https://api.example.com/v1" });
44
+
45
+ // GET /pets/{petId}
46
+ const { data, error, response } = await client.GET("/pets/{petId}", {
47
+ params: { path: { petId: "123" } },
48
+ });
49
+
50
+ if (error) {
51
+ // `error` is typed from the operation's 4XX/5XX/default responses
52
+ console.error(response.status, error);
53
+ } else {
54
+ // `data` is typed from the operation's 2XX response
55
+ console.log(data.name);
56
+ }
57
+
58
+ // POST /pets with a typed JSON body
59
+ const created = await client.POST("/pets", { body: { name: "Fido" } });
60
+ ```
61
+
62
+ TypeScript rejects unknown paths, a method the path does not define, a missing required parameter or body, and a body of the wrong shape.
63
+
64
+ ## How requests and responses work
65
+
66
+ Each call returns `Promise<FetchResponse>`, a discriminated union:
67
+
68
+ ```ts
69
+ | { data: SuccessType; error?: never; response: Response }
70
+ | { data?: never; error: ErrorType; response: Response }
71
+ ```
72
+
73
+ - **`data`** is set when `response.ok` (status 200–299). By default it is parsed as JSON. Its type is the union of the operation's 2XX response bodies.
74
+ - **`error`** is set when the status is not OK. The body is read as text and then parsed as JSON. If parsing fails, `error` is the raw text. Its type is the union of the operation's 4XX, 5XX, and `default` response bodies.
75
+ - **`response`** is always the final `Response` (after any `onResponse` middleware).
76
+ - **Empty bodies:** a 204 response, any `HEAD` request, or `Content-Length: 0` (without chunked transfer encoding) gives `data: undefined` (OK) or `error: undefined` (not OK). The same happens for an empty non-OK body and for an OK JSON response with no `Content-Length` and an empty body. When the body is empty, check `response.status` or `response.ok` rather than relying on `data`/`error` being truthy.
77
+ - **No throw on HTTP errors.** 4XX and 5XX responses resolve normally. The promise rejects only when:
78
+ - `fetch` itself throws (network error, abort) and no `onError` middleware handles it.
79
+ - Middleware or a serializer throws or rejects.
80
+ - Middleware returns an invalid value (see [Middleware](#middleware)).
81
+ - A successful response contains invalid JSON while `parseAs` is `"json"`.
82
+ - Request construction fails, for example because the URL or request options are invalid.
83
+ - A query parameter contains a nested object or array (see [Limitations](#limitations-and-caveats)).
84
+ - Requests default to `redirect: "follow"`.
85
+
86
+ ## API reference
87
+
88
+ The package's single entry point (`@shipworthy/openapi-fetch`) provides these exports.
89
+
90
+ ### `createClient<Paths, Media>(options?)` (default and named export)
91
+
92
+ ```ts
93
+ function createClient<Paths extends {}, Media extends MediaType = MediaType>(
94
+ clientOptions?: ClientOptions,
95
+ ): Client<Paths, Media>;
96
+ ```
97
+
98
+ - `Paths` is the generated `paths` interface.
99
+ - `Media` narrows which response media types count toward `data`/`error`. It defaults to any `${string}/${string}`. For example, pass `` `${string}/json` `` to type only JSON responses.
100
+
101
+ #### `ClientOptions`
102
+
103
+ `ClientOptions` accepts every `RequestInit` field except `headers` (for example `credentials`, `mode`, `cache`, `signal`, `redirect`). These are applied to every request. It also accepts the following:
104
+
105
+ | Option | Type | Default | Description |
106
+ | ----------------- | ------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | `baseUrl` | `string` | `""` | Prefix for every request URL. A trailing `/` is removed. |
108
+ | `headers` | `HeadersOptions` | none | Headers sent with every request (see [Headers](#headers)). |
109
+ | `fetch` | `(input: Request) => Promise<Response>` | `globalThis.fetch` | Custom fetch implementation. It is called with a single `Request` (plus `requestInitExt` on Node). |
110
+ | `Request` | `typeof Request` | `globalThis.Request` | Custom `Request` constructor. |
111
+ | `querySerializer` | `QuerySerializer \| QuerySerializerOptions` | form/explode for arrays, deepObject/explode for objects | Global query string serialization (see [Query serialization](#query-serialization)). |
112
+ | `bodySerializer` | `BodySerializer` | `defaultBodySerializer` | Global request body serializer. |
113
+ | `pathSerializer` | `PathSerializer` | `defaultPathSerializer` | Global path template serializer. |
114
+ | `requestInitExt` | `Record<string, unknown>` | `undefined` | Passed as the second argument to `fetch`. It is only used on Node.js 18+ with undici (detected through `process.versions.undici`) and ignored elsewhere, including Bun. Example: an undici `dispatcher`. |
115
+
116
+ #### `Client<Paths, Media>`
117
+
118
+ | Member | Description |
119
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
120
+ | `GET(url, init?)`, `PUT`, `POST`, `DELETE`, `OPTIONS`, `HEAD`, `PATCH`, `TRACE` | Call an endpoint. `url` must be a path in `Paths` that defines that method. Use the template form, such as `"/pets/{petId}"`, not a pre-filled URL. |
121
+ | `request(method, url, init?)` | Same as above with a lowercase `HttpMethod` (`"get"`, `"post"`, ...). The method is uppercased at runtime. |
122
+ | `use(...middleware)` | Register client-level middleware. Falsy entries are skipped. Throws `Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`")` for anything else that is invalid. |
123
+ | `eject(...middleware)` | Unregister middleware by reference. |
124
+
125
+ The `init` argument is required when the operation has required parameters or a required body. Otherwise it is optional.
126
+
127
+ #### Per-request options (`init`)
128
+
129
+ `init` accepts every `RequestInit` field except `body` and `headers` (for example `signal`, `cache`, `credentials`). It also accepts:
130
+
131
+ | Option | Description |
132
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
133
+ | `params.path` | Values for `{placeholders}` in the path. |
134
+ | `params.query` | Query parameters. `undefined`, `null`, and empty arrays are omitted. |
135
+ | `params.header` | Header parameters. They have the highest header precedence. |
136
+ | `params.cookie` | Cookie parameters. They are serialized into the `Cookie` header (see [Cookies](#cookies)) and are also available to middleware as `params.cookie`. |
137
+ | `body` | Request body, typed from the operation's `requestBody`. Required when the schema marks the body as required. |
138
+ | `headers` | Per-request headers, in any `HeadersOptions` form (see [Headers](#headers)). |
139
+ | `baseUrl` | Overrides the client `baseUrl` for this request. |
140
+ | `parseAs` | `"json"` (default), `"text"`, `"blob"`, `"arrayBuffer"`, or `"stream"`. It changes how and whether `data` is parsed. The type of `data` follows it: `"text"` gives `string`, `"stream"` gives `ReadableStream \| null`, and so on. It does not affect how `error` is parsed. |
141
+ | `querySerializer` | A function replaces the serializer. An options object is merged over the client-level options object. |
142
+ | `bodySerializer` | Overrides the body serializer. |
143
+ | `pathSerializer` | Overrides the path serializer. |
144
+ | `fetch` | Overrides the fetch implementation. |
145
+ | `middleware` | `Middleware[]` for this request only. These run after client-level middleware. |
146
+
147
+ Any other key you pass is copied onto the `Request` object if `Request` does not already have a property with that name. Middleware can then read it.
148
+
149
+ ```ts
150
+ const controller = new AbortController();
151
+
152
+ const { data } = await client.GET("/pets", {
153
+ params: { query: { tags: ["dog", "cat"], limit: 10 } }, // ?tags=dog&tags=cat&limit=10
154
+ headers: { "Accept-Language": "en" },
155
+ signal: controller.signal,
156
+ });
157
+
158
+ const { data: csv } = await client.GET("/reports/{id}", {
159
+ params: { path: { id: "q3" } },
160
+ parseAs: "text", // data: string
161
+ });
162
+ ```
163
+
164
+ ### `createPathBasedClient<Paths, Media>(options?)` and `wrapAsPathBasedClient(client)`
165
+
166
+ These provide an alternative API that indexes by path first and then calls an uppercase method:
167
+
168
+ ```ts
169
+ import createClient, {
170
+ createPathBasedClient,
171
+ wrapAsPathBasedClient,
172
+ } from "@shipworthy/openapi-fetch";
173
+ import type { paths } from "./api/schema";
174
+
175
+ const api = createPathBasedClient<paths>({ baseUrl: "https://api.example.com/v1" });
176
+ const { data, error } = await api["/pets/{petId}"].GET({ params: { path: { petId: "123" } } });
177
+
178
+ // Or wrap an existing client
179
+ const client = createClient<paths>({ baseUrl: "https://api.example.com/v1" });
180
+ const api2 = wrapAsPathBasedClient(client);
181
+ ```
182
+
183
+ - `createPathBasedClient(options)` is exactly `wrapAsPathBasedClient(createClient(options))`.
184
+ - Both return `PathBasedClient<Paths, Media>`. Each path key exposes only the methods the schema defines for that path, uppercased.
185
+ - The path-based client has no `use`/`eject`. To add middleware, create the client with `createClient`, call `client.use(...)`, and then wrap it.
186
+
187
+ ### Middleware
188
+
189
+ ```ts
190
+ import createClient, { type Middleware } from "@shipworthy/openapi-fetch";
191
+
192
+ const auth: Middleware = {
193
+ async onRequest({ request }) {
194
+ request.headers.set("Authorization", `Bearer ${await getToken()}`);
195
+ return request;
196
+ },
197
+ onResponse({ response }) {
198
+ if (response.status === 401) signOut();
199
+ // return nothing to keep the response unchanged
200
+ },
201
+ onError({ error }) {
202
+ // fetch threw (network failure, abort, ...)
203
+ return new Error("Network request failed", { cause: error });
204
+ },
205
+ };
206
+
207
+ const client = createClient<paths>({ baseUrl: "https://api.example.com/v1" });
208
+ client.use(auth);
209
+ // later: client.eject(auth);
210
+ ```
211
+
212
+ A `Middleware` is an object with at least one of `onRequest`, `onResponse`, or `onError`. Each hook receives `MiddlewareCallbackParams`:
213
+
214
+ | Field | Description |
215
+ | ------------ | ------------------------------------------------------------------------------------------------------------- |
216
+ | `request` | The current `Request`. |
217
+ | `schemaPath` | The schema path template, for example `"/pets/{petId}"`. |
218
+ | `params` | The `params` passed to the call (`query`, `header`, `path`, `cookie`). |
219
+ | `id` | A random ID for this request, shared across its hooks. It is not cryptographically secure. |
220
+ | `options` | Frozen `MergedOptions`: `baseUrl`, `parseAs`, `querySerializer`, `bodySerializer`, `pathSerializer`, `fetch`. |
221
+ | `response` | Present for `onResponse` only. |
222
+ | `error` | Present for `onError` only. This is the thrown value. |
223
+
224
+ The order of the hooks and their valid return values:
225
+
226
+ - **`onRequest`** runs in registration order, client-level first and then per-request `middleware`.
227
+ - Return a `Request` to replace the request.
228
+ - Return a `Response` to skip `fetch`, the remaining `onRequest` hooks, and **all** `onResponse` hooks. The returned `Response` is used as-is: no `onResponse` hook runs for it, including hooks registered before the one that returned it. This matches upstream openapi-fetch.
229
+ - Return `undefined` to leave the request unchanged.
230
+ - Any other truthy value throws `Error("onRequest: must return new Request() or Response() when modifying the request")`.
231
+ - **`onError`** runs in reverse order, and only when `fetch` throws.
232
+ - Return a `Response` to recover. The remaining `onError` hooks are skipped, and the `onResponse` hooks still run.
233
+ - Return an `Error` to replace the error passed to the next hook.
234
+ - Return `undefined` to pass the error through unchanged.
235
+ - Any other truthy value throws. If no hook returns a `Response`, the final error is rethrown.
236
+ - **`onResponse`** runs in reverse order (the first-registered middleware sees the response last).
237
+ - Return a `Response` to replace it.
238
+ - Return `undefined` to keep it.
239
+ - Any other truthy value throws `Error("onResponse: must return new Response() when modifying the response")`.
240
+
241
+ ### Headers
242
+
243
+ Headers are merged in this order, from lowest to highest precedence:
244
+
245
+ 1. `Content-Type: application/json`. This is added only when a body is present and the serialized body is not `FormData`.
246
+ 2. Client `headers`.
247
+ 3. Per-request `headers`.
248
+ 4. `params.header`.
249
+
250
+ The values in a `HeadersOptions` object follow these rules:
251
+
252
+ - `null` removes the header.
253
+ - `undefined` is ignored.
254
+ - Arrays append one value per element.
255
+ - Numbers and booleans are converted to strings.
256
+
257
+ Client and per-request `headers` accept every `HeadersInit` form as well as the object form above:
258
+
259
+ - A plain object, using the rules above.
260
+ - A `Headers` instance.
261
+ - An array of `[name, value]` tuples. Repeated names are combined as `Headers` would combine them, and the result overrides lower-precedence values for those names.
262
+
263
+ `null` removal only applies to the plain object form.
264
+
265
+ ```ts
266
+ const client = createClient<paths>({
267
+ baseUrl: "https://api.example.com/v1",
268
+ headers: { "X-Client": "web", "Accept-Language": "en" },
269
+ });
270
+
271
+ // Override one client header and remove another for a single request
272
+ await client.GET("/pets", { headers: { "X-Client": "cli", "Accept-Language": null } });
273
+
274
+ // Tuple arrays and Headers instances work at either level
275
+ await client.GET("/pets", { headers: [["X-Request-Id", "123"]] });
276
+ ```
277
+
278
+ #### `mergeHeaders(...headers)`
279
+
280
+ ```ts
281
+ function mergeHeaders(...allHeaders: (HeadersOptions | undefined)[]): Headers;
282
+ ```
283
+
284
+ Merges the arguments left to right using the rules above. Later arguments win.
285
+
286
+ ```ts
287
+ mergeHeaders({ A: "1", B: "2" }, { B: null, C: ["x", "y"] }); // Headers { a: "1", c: "x, y" }
288
+ ```
289
+
290
+ ### Cookies
291
+
292
+ `params.cookie` is serialized into a `Cookie` header with the OpenAPI default for cookies (`style: form`, `explode: true`):
293
+
294
+ - Primitives become `name=value`. Values are URI-encoded with `encodeURIComponent`.
295
+ - Arrays repeat the name: `{ tags: ["a", "b"] }` becomes `tags=a; tags=b`.
296
+ - Objects emit one pair per key: `{ prefs: { theme: "dark" } }` becomes `theme=dark`.
297
+ - `undefined` and `null` values are skipped. If nothing remains, no `Cookie` header is added.
298
+
299
+ Pairs are joined with `; ` and appended after any `Cookie` header already set through client `headers`, per-request `headers`, or `params.header`.
300
+
301
+ ```ts
302
+ await client.GET("/session", {
303
+ headers: { Cookie: "theme=dark" },
304
+ params: { cookie: { session: "abc 123" } },
305
+ });
306
+ // Cookie: theme=dark; session=abc%20123
307
+ ```
308
+
309
+ Browsers treat `Cookie` as a forbidden request header and drop it from `fetch`. In a browser, rely on `credentials: "include"` and the browser's cookie jar instead. Server runtimes such as Node.js and Bun send the header.
310
+
311
+ ### Request bodies
312
+
313
+ #### `defaultBodySerializer(body, headers?)`
314
+
315
+ ```ts
316
+ function defaultBodySerializer<T>(
317
+ body: T,
318
+ headers?: Headers | Record<string, unknown>,
319
+ ): string | FormData;
320
+ ```
321
+
322
+ The default serializer handles bodies as follows:
323
+
324
+ - A `FormData` instance is passed through unchanged. No `Content-Type` is set, so the runtime adds the multipart boundary.
325
+ - If the merged `Content-Type` is exactly `application/x-www-form-urlencoded`, the body is converted with `new URLSearchParams(body).toString()`.
326
+ - Anything else goes through `JSON.stringify(body)`.
327
+ - If `body` is `undefined`, nothing is sent.
328
+
329
+ ```ts
330
+ // multipart/form-data
331
+ const form = new FormData();
332
+ form.append("file", file);
333
+ await client.POST("/files", { body: form as any }); // cast if the schema types the body as an object
334
+
335
+ // application/x-www-form-urlencoded
336
+ await client.POST("/token", {
337
+ body: { grant_type: "client_credentials" },
338
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
339
+ });
340
+
341
+ // custom serializer
342
+ await client.POST("/pets", {
343
+ body: { name: "Fido" },
344
+ bodySerializer: (body) => JSON.stringify({ data: body }),
345
+ });
346
+ ```
347
+
348
+ A custom `BodySerializer<T>` has the signature `(body, headers?: Headers) => any`. Its return value becomes the `RequestInit.body`.
349
+
350
+ ### Query serialization
351
+
352
+ By default, query parameters follow the OpenAPI defaults:
353
+
354
+ | Value | Default style | Example |
355
+ | --------- | ----------------------------- | --------------- |
356
+ | Primitive | `name=value` (URI-encoded) | `s=a%20b` |
357
+ | Array | `form`, `explode: true` | `tags=a&tags=b` |
358
+ | Object | `deepObject`, `explode: true` | `filter[x]=1` |
359
+
360
+ To change the style, pass `QuerySerializerOptions` to `createClient` or to a single request:
361
+
362
+ ```ts
363
+ type QuerySerializerOptions = {
364
+ array?: { style: "form" | "spaceDelimited" | "pipeDelimited"; explode: boolean };
365
+ object?: { style: "form" | "deepObject"; explode: boolean };
366
+ allowReserved?: boolean; // default false: reserved characters are percent-encoded
367
+ };
368
+ ```
369
+
370
+ ```ts
371
+ const client = createClient<paths>({
372
+ baseUrl: "https://api.example.com/v1",
373
+ querySerializer: { array: { style: "form", explode: false } }, // tags=a,b
374
+ });
375
+
376
+ // Or replace serialization entirely:
377
+ await client.GET("/search", {
378
+ params: { query: { q: "x" } },
379
+ querySerializer: (query) => new URLSearchParams(query as Record<string, string>).toString(),
380
+ });
381
+ ```
382
+
383
+ A leading `?` returned by a custom serializer is stripped.
384
+
385
+ #### `createQuerySerializer(options?)`
386
+
387
+ ```ts
388
+ function createQuerySerializer<T = unknown>(
389
+ options?: QuerySerializerOptions,
390
+ ): (queryParams: T) => string;
391
+ ```
392
+
393
+ Returns the serializer that the client uses internally. The returned string has no leading `?`.
394
+
395
+ ```ts
396
+ createQuerySerializer()({ a: [1, 2], o: { x: 1 }, s: "a b", n: null });
397
+ // "a=1&a=2&o[x]=1&s=a%20b"
398
+ createQuerySerializer({
399
+ array: { style: "form", explode: false },
400
+ object: { style: "form", explode: false },
401
+ })({ a: [1, 2], o: { x: 1, y: 2 } });
402
+ // "a=1,2&o=x,1,y,2"
403
+ ```
404
+
405
+ ### Path serialization
406
+
407
+ #### `defaultPathSerializer(pathname, pathParams)`
408
+
409
+ ```ts
410
+ function defaultPathSerializer(pathname: string, pathParams: Record<string, unknown>): string;
411
+ ```
412
+
413
+ Replaces `{name}` placeholders with URI-encoded values. The placeholder syntax selects the OpenAPI path style:
414
+
415
+ | Placeholder | Style |
416
+ | ----------- | ------------------------------------------ |
417
+ | `{id}` | `simple` |
418
+ | `{.id}` | `label` |
419
+ | `{;id}` | `matrix` |
420
+ | `{id*}` | explode (combine it with any of the above) |
421
+
422
+ Arrays and objects are serialized with `serializeArrayParam` and `serializeObjectParam`. A placeholder whose value is `undefined` or `null` is left in the URL unchanged.
423
+
424
+ ```ts
425
+ defaultPathSerializer("/a/{id}/{.lbl}/{;m}/{arr*}", { id: "a/b", lbl: "x", m: 5, arr: [1, 2] });
426
+ // "/a/a%2Fb/.x/;m=5/1,2"
427
+ ```
428
+
429
+ A custom `PathSerializer` has the signature `(pathname: string, pathParams: Record<string, unknown>) => string`. The `pathname` it receives already includes the `baseUrl`.
430
+
431
+ ### Parameter serializers
432
+
433
+ These are low-level building blocks for writing your own query or path serializer.
434
+
435
+ ```ts
436
+ function serializePrimitiveParam(
437
+ name: string,
438
+ value: string | number | boolean | null | undefined,
439
+ options?: { allowReserved?: boolean },
440
+ ): string; // "name=value"; "" for null/undefined; throws for objects
441
+
442
+ function serializeObjectParam(
443
+ name: string,
444
+ value: Record<string, unknown>,
445
+ options: {
446
+ style: "simple" | "label" | "matrix" | "form" | "deepObject";
447
+ explode: boolean;
448
+ allowReserved?: boolean;
449
+ },
450
+ ): string;
451
+
452
+ function serializeArrayParam(
453
+ name: string,
454
+ value: unknown[],
455
+ options: {
456
+ style: "simple" | "label" | "matrix" | "form" | "spaceDelimited" | "pipeDelimited";
457
+ explode: boolean;
458
+ allowReserved?: boolean;
459
+ },
460
+ ): string;
461
+ ```
462
+
463
+ `serializePrimitiveParam` throws `Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.")` when given an object.
464
+
465
+ ### URL helpers
466
+
467
+ ```ts
468
+ function createFinalURL<O>(
469
+ pathname: string,
470
+ options: {
471
+ baseUrl: string;
472
+ params: { query?: Record<string, unknown>; path?: Record<string, unknown> };
473
+ querySerializer: QuerySerializer<O>;
474
+ pathSerializer: PathSerializer;
475
+ },
476
+ ): string; // `${baseUrl}${pathname}` with path params applied and `?query` appended when non-empty
477
+
478
+ function removeTrailingSlash(url: string): string; // removes one trailing "/"
479
+ ```
480
+
481
+ ### Types
482
+
483
+ Client and request types:
484
+
485
+ | Type | Purpose |
486
+ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
487
+ | `Client<Paths, Media>` | Return type of `createClient`. |
488
+ | `ClientOptions` | Options for `createClient`. |
489
+ | `PathBasedClient<Paths, Media>`, `ClientForPath<PathInfo, Media>` | Path-based client shapes. |
490
+ | `ClientMethod`, `ClientRequestMethod` | Signatures of `GET`/`POST`/... and of `request`. |
491
+ | `FetchOptions<Op>`, `RequestOptions<Op>`, `MaybeOptionalInit<PathItem, Method>` | Shape of the `init` argument for an operation. |
492
+ | `ParamsOption<Op>`, `RequestBodyOption<Op>`, `DefaultParamsOption` | The `params` and `body` parts of `init`. |
493
+ | `FetchResponse<Op, Init, Media>` | The `{ data, error, response }` union. |
494
+ | `ParseAs`, `ParseAsResponse<T, Options>` | `parseAs` values and the resulting `data` type. |
495
+ | `HeadersOptions`, `QuerySerializer<T>`, `QuerySerializerOptions`, `BodySerializer<T>`, `PathSerializer` | Option types. |
496
+ | `Middleware`, `MiddlewareCallbackParams`, `MiddlewareRequestParams`, `MergedOptions` | Middleware types. |
497
+ | `ClientPathsWithMethod<Client, Method>` | The paths of a client instance that support `Method`. |
498
+ | `MethodResponse<Client, Method, Path, Options?>` | The non-nullable `data` type for a call. |
499
+
500
+ Schema helpers:
501
+
502
+ | Type | Purpose |
503
+ | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
504
+ | `HttpMethod` | `"get" \| "put" \| "post" \| "delete" \| "options" \| "head" \| "patch" \| "trace"`. |
505
+ | `OkStatus`, `ErrorStatus` | The status codes treated as success, and as error (including `"4XX"`, `"5XX"`, and `default`). |
506
+ | `PathsWithMethod<Paths, Method>` | The paths that define `Method`. |
507
+ | `SuccessResponse<Responses, Media>`, `ErrorResponse<Responses, Media>` | Success or error body union from a responses map. |
508
+ | `SuccessResponseJSON<Op>`, `ErrorResponseJSON<Op>`, `RequestBodyJSON<Op>` | JSON-only variants that take an operation. |
509
+ | `ResponseObjectMap`, `ResponseContent`, `GetResponseContent`, `OKStatusUnion`, `FirstErrorStatus` | Response-map utilities. |
510
+ | `OperationRequestBody`, `OperationRequestBodyContent`, `OperationRequestBodyMediaContent`, `IsOperationRequestBodyOptional` | Request-body utilities. |
511
+ | `OperationObject`, `PathItemObject` | Structural helpers used for inference. |
512
+ | `MediaType`, `JSONLike<T>` | `` `${string}/${string}` `` and a JSON media-type filter. |
513
+ | `FilterKeys`, `RequiredKeysOf` | Generic utilities. |
514
+ | `$Read<T>`, `$Write<T>`, `Readable<T>`, `Writable<T>` | readOnly/writeOnly support (see below). |
515
+
516
+ ```ts
517
+ import type { MethodResponse } from "@shipworthy/openapi-fetch";
518
+
519
+ type Pet = MethodResponse<typeof client, "get", "/pets/{petId}">;
520
+ ```
521
+
522
+ #### readOnly / writeOnly properties
523
+
524
+ If you generate types with `openapi generate typescript --read-write-markers`, `readOnly` properties are wrapped in `$Read<T>` and `writeOnly` properties in `$Write<T>`. The client then:
525
+
526
+ - removes `$Read` properties from request `body` types, which reject them with `?: never`;
527
+ - removes `$Write` properties from `data`/`error` types;
528
+ - unwraps the remaining markers.
529
+
530
+ Without the flag, the types are unchanged.
531
+
532
+ ## Supported OpenAPI versions
533
+
534
+ `openapi-fetch` works with whatever `paths` type it is given. With the `@shipworthy` toolchain, OpenAPI 3.0 documents are upgraded to 3.1 before types are generated, so both 3.0 and 3.1 schemas work. Path, query, header, and cookie parameters, request bodies of any media type, and responses keyed by status code, `"2XX"`/`"4XX"`/`"5XX"`, or `default` are all reflected in the types.
535
+
536
+ ## Limitations and caveats
537
+
538
+ - **No runtime validation.** Response data is cast to the generated type, not checked. To validate, use a runtime validator such as `@shipworthy/openapi-zod`.
539
+ - **HTTP errors do not throw.** Always check `error` (or `response.ok`).
540
+ - **Only the first level of query values.** Nested arrays or objects inside query values throw unless you provide your own `querySerializer`.
541
+ - **Cookie parameters are dropped in browsers.** The `Cookie` header is forbidden there, so use `credentials` instead (see [Cookies](#cookies)).
542
+ - **An `onRequest` hook that returns a `Response` skips every `onResponse` hook.** This matches upstream openapi-fetch.
543
+ - **`parseAs` affects only `data`.** Error bodies are always read as text, with JSON parsing attempted.
544
+ - **Avoid empty-body truthiness checks.** `data` can be `undefined` on a successful empty response (for example 204). Use `response.status` to tell the cases apart.
545
+ - **Call methods with the path template, not a filled-in URL.** Write `client.GET("/pets/{petId}", { params: { path: { petId } } })`, not ``client.GET(`/pets/${id}`)``.
546
+ - **`FormData` bodies may need a cast.** If the schema types a multipart body as an object, TypeScript will not accept a `FormData` value without a cast.
547
+
548
+ ## Troubleshooting
549
+
550
+ - **"Argument of type ... is not assignable" on the path.** The path or method is not in your `paths` type. Regenerate the types after the schema changes.
551
+ - **`init` is unexpectedly required or optional.** Requiredness comes from the schema's `required` flags. Make sure `strictNullChecks` is on.
552
+ - **Wrong URL (`//` or a missing segment).** A trailing `/` on `baseUrl` is removed, so paths should start with `/`.
553
+ - **`requestInitExt` has no effect.** It is only forwarded on Node.js with undici, not in Bun or browsers.
554
+ - **`Error: onRequest: must return new Request() or Response()...`.** A middleware hook returned something other than a `Request`, a `Response`, or `undefined`.
555
+ - **Uncaught `SyntaxError` on a successful response.** The server sent a `Content-Length` with a non-JSON body. Pass `parseAs: "text"` (or another `parseAs`) for that call.
556
+
557
+ ## Key facts (for AI agents)
558
+
559
+ - Import: `import createClient from "@shipworthy/openapi-fetch"`, a **default** export, also available as the named export `createClient`. Compiled ESM and CommonJS with `.d.ts`/`.d.cts` declarations and no dependencies.
560
+ - Typical setup: `createClient<paths>({ baseUrl })`, where `paths` comes from `openapi generate typescript <schema> -o schema.ts`.
561
+ - Call signature: `client.GET("/template/{param}", { params: { path, query, header, cookie }, body, headers, parseAs, signal })`. Methods are uppercase: `GET PUT POST DELETE OPTIONS HEAD PATCH TRACE`. `client.request("get", path, init)` is also available.
562
+ - Result: `{ data, error, response }`. The promise does not reject on 4XX/5XX. It rejects on network errors, invalid JSON in a successful response that has a `Content-Length`, and misbehaving middleware.
563
+ - Default serialization:
564
+ - Body is JSON with `Content-Type: application/json`.
565
+ - `FormData` passes through.
566
+ - `application/x-www-form-urlencoded` produces `URLSearchParams`.
567
+ - Query arrays repeat the key (`a=1&a=2`), and query objects use `deepObject` (`o[x]=1`).
568
+ - Header precedence: client `headers` < request `headers` < `params.header`. `null` deletes a header. `headers` accepts a plain object, a `Headers` instance, or `[name, value]` tuples.
569
+ - `params.cookie` is sent as a `Cookie` header (form/explode, URI-encoded), appended to any existing `Cookie` header.
570
+ - Middleware: `client.use({ onRequest, onResponse, onError })`. `onRequest` runs in order; `onResponse` and `onError` run in reverse.
571
+ - Path-based alternative: `createPathBasedClient<paths>(opts)["/pets/{id}"].GET(init)`.
572
+ - Response data is not validated at runtime.
573
+
574
+ ## License
575
+
576
+ MIT. See [LICENSE](./LICENSE).