openapi-typesafe-fetch 1.0.0-rc.0 → 1.0.0-rc.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/README.md +10 -3
- package/dist/index.cjs +66 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +245 -46
- package/dist/index.d.mts +244 -43
- package/dist/index.d.ts +245 -46
- package/dist/index.mjs +66 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -8
- package/src/index.d.ts +243 -42
- package/src/index.js +121 -47
package/src/index.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// settings & const
|
|
2
2
|
const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* A `.` or `..` path segment; URLs resolve it, so a parameter value could leave its path (#2483).
|
|
5
|
+
*/
|
|
4
6
|
const DOT_SEGMENT_RE = /(^|\/)\.\.?(\/|$)/;
|
|
5
|
-
/**
|
|
7
|
+
/** Where the path of a URL ends: its query or fragment, which have no segments. */
|
|
8
|
+
const QUERY_OR_FRAGMENT_RE = /[?#]/;
|
|
9
|
+
/**
|
|
10
|
+
* Any JSON media type: `application/json`, `application/problem+json`, with or without
|
|
11
|
+
* parameters.
|
|
12
|
+
*/
|
|
6
13
|
const JSON_MEDIA_TYPE_RE = /json/i;
|
|
7
|
-
|
|
14
|
+
/** `application/x-www-form-urlencoded`, with or without parameters such as `charset`. */
|
|
15
|
+
const FORM_MEDIA_TYPE_RE = /^application\/x-www-form-urlencoded(;|$)/i;
|
|
8
16
|
/** `multipart/form-data`, with or without parameters. */
|
|
9
17
|
const MULTIPART_MEDIA_TYPE_RE = /^multipart\/form-data(;|$)/i;
|
|
10
18
|
const NESTED_FORM_VALUE_MESSAGE = "Nested form values need your own `bodySerializer()`.";
|
|
@@ -39,6 +47,7 @@ export function randomID() {
|
|
|
39
47
|
* Create an openapi-typesafe-fetch client.
|
|
40
48
|
* @type {import("./index.js").default}
|
|
41
49
|
*/
|
|
50
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
42
51
|
export default function createClient(clientOptions) {
|
|
43
52
|
let {
|
|
44
53
|
baseUrl = "",
|
|
@@ -60,6 +69,7 @@ export default function createClient(clientOptions) {
|
|
|
60
69
|
* @param {T} url
|
|
61
70
|
* @param {import('./index.js').FetchOptions<T>} fetchOptions
|
|
62
71
|
*/
|
|
72
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
63
73
|
async function coreFetch(schemaPath, fetchOptions) {
|
|
64
74
|
const {
|
|
65
75
|
baseUrl: localBaseUrl,
|
|
@@ -101,11 +111,12 @@ export default function createClient(clientOptions) {
|
|
|
101
111
|
? undefined
|
|
102
112
|
: bodySerializer(
|
|
103
113
|
body,
|
|
104
|
-
// Note: we declare mergeHeaders() both here and below because it’s a bit of a
|
|
105
|
-
// bodySerializer() needs all headers so we aren’t dropping
|
|
106
|
-
// the result of this ALSO sets the lowest-priority
|
|
107
|
-
// setting the content-type at the very
|
|
108
|
-
// Lastly, based on the way headers work, it’s not a simple
|
|
114
|
+
// Note: we declare mergeHeaders() both here and below because it’s a bit of a
|
|
115
|
+
// chicken-or-egg situation: bodySerializer() needs all headers so we aren’t dropping
|
|
116
|
+
// ones set by the user, however, the result of this ALSO sets the lowest-priority
|
|
117
|
+
// content-type header. So we re-merge below, setting the content-type at the very
|
|
118
|
+
// beginning to be overwritten. Lastly, based on the way headers work, it’s not a simple
|
|
119
|
+
// “present-or-not” check because null intentionally un-sets headers.
|
|
109
120
|
mergeHeaders(baseHeaders, headers, params.header),
|
|
110
121
|
);
|
|
111
122
|
const finalHeaders = mergeHeaders(
|
|
@@ -124,7 +135,10 @@ export default function createClient(clientOptions) {
|
|
|
124
135
|
// `fetch` sends a FormData as multipart with a boundary only it knows. A `Content-Type` without
|
|
125
136
|
// that boundary, set by hand, replaced the platform's, and the server could not split the parts
|
|
126
137
|
// (upstream #1214), so it goes and the platform sets its own (ADR-013, decision 10).
|
|
127
|
-
if (
|
|
138
|
+
if (
|
|
139
|
+
serializedBody instanceof FormData &&
|
|
140
|
+
!BOUNDARY_PARAMETER_RE.test(finalHeaders.get("Content-Type"))
|
|
141
|
+
) {
|
|
128
142
|
finalHeaders.delete("Content-Type");
|
|
129
143
|
}
|
|
130
144
|
|
|
@@ -142,7 +156,12 @@ export default function createClient(clientOptions) {
|
|
|
142
156
|
let id;
|
|
143
157
|
let options;
|
|
144
158
|
let request = new Request(
|
|
145
|
-
createFinalURL(schemaPath, {
|
|
159
|
+
createFinalURL(schemaPath, {
|
|
160
|
+
baseUrl: finalBaseUrl,
|
|
161
|
+
params,
|
|
162
|
+
querySerializer,
|
|
163
|
+
pathSerializer,
|
|
164
|
+
}),
|
|
146
165
|
requestInit,
|
|
147
166
|
);
|
|
148
167
|
let response;
|
|
@@ -176,13 +195,15 @@ export default function createClient(clientOptions) {
|
|
|
176
195
|
id,
|
|
177
196
|
});
|
|
178
197
|
if (result) {
|
|
179
|
-
if (result
|
|
198
|
+
if (isRequest(result)) {
|
|
180
199
|
request = result;
|
|
181
|
-
} else if (result
|
|
200
|
+
} else if (isResponse(result)) {
|
|
182
201
|
response = result;
|
|
183
202
|
break;
|
|
184
203
|
} else {
|
|
185
|
-
throw new Error(
|
|
204
|
+
throw new Error(
|
|
205
|
+
"onRequest: must return new Request() or Response() when modifying the request",
|
|
206
|
+
);
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
209
|
}
|
|
@@ -211,7 +232,7 @@ export default function createClient(clientOptions) {
|
|
|
211
232
|
});
|
|
212
233
|
if (result) {
|
|
213
234
|
// if error is handled by returning a response, skip remaining middleware
|
|
214
|
-
if (result
|
|
235
|
+
if (isResponse(result)) {
|
|
215
236
|
errorAfterMiddleware = undefined;
|
|
216
237
|
response = result;
|
|
217
238
|
break;
|
|
@@ -249,8 +270,10 @@ export default function createClient(clientOptions) {
|
|
|
249
270
|
id,
|
|
250
271
|
});
|
|
251
272
|
if (result) {
|
|
252
|
-
if (!(result
|
|
253
|
-
throw new Error(
|
|
273
|
+
if (!isResponse(result)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
"onResponse: must return new Response() when modifying the response",
|
|
276
|
+
);
|
|
254
277
|
}
|
|
255
278
|
response = result;
|
|
256
279
|
}
|
|
@@ -278,7 +301,8 @@ export default function createClient(clientOptions) {
|
|
|
278
301
|
}
|
|
279
302
|
|
|
280
303
|
if (parseAs === "json" && !contentLength) {
|
|
281
|
-
// use text() when no content-length is provided to avoid errors parsing empty bodies
|
|
304
|
+
// use text() when no content-length is provided to avoid errors parsing empty bodies
|
|
305
|
+
// (200 with no content)
|
|
282
306
|
const raw = await response.text();
|
|
283
307
|
return raw ? JSON.parse(raw) : undefined;
|
|
284
308
|
}
|
|
@@ -350,7 +374,9 @@ export default function createClient(clientOptions) {
|
|
|
350
374
|
continue;
|
|
351
375
|
}
|
|
352
376
|
if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) {
|
|
353
|
-
throw new Error(
|
|
377
|
+
throw new Error(
|
|
378
|
+
"Middleware must be an object with one of `onRequest()`, `onResponse()` or `onError()`",
|
|
379
|
+
);
|
|
354
380
|
}
|
|
355
381
|
globalMiddlewares.push(m);
|
|
356
382
|
}
|
|
@@ -451,6 +477,25 @@ export function createPathBasedClient(clientOptions) {
|
|
|
451
477
|
|
|
452
478
|
// utils
|
|
453
479
|
|
|
480
|
+
// Fetch API objects are recognized by the members the client uses, not by `instanceof`: that is
|
|
481
|
+
// bound to one realm's class, and a Request from the `undici` package, another realm (an iframe,
|
|
482
|
+
// an Electron host) or a custom `Request` option is not an instance of it (upstream #1847). The
|
|
483
|
+
// same rule as for headers in `defaultBodySerializer` (upstream #2637).
|
|
484
|
+
|
|
485
|
+
function isHeaders(value) {
|
|
486
|
+
return typeof value?.get === "function";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isRequest(value) {
|
|
490
|
+
return (
|
|
491
|
+
typeof value?.method === "string" && typeof value.url === "string" && isHeaders(value.headers)
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isResponse(value) {
|
|
496
|
+
return typeof value?.status === "number" && isHeaders(value.headers);
|
|
497
|
+
}
|
|
498
|
+
|
|
454
499
|
// Parameter names and object keys can be data (an `additionalProperties` object). They are encoded
|
|
455
500
|
// like values, except for characters common in names that do not split a URL (ADR-013, decision 9).
|
|
456
501
|
const KEPT_NAME_ESCAPES = /%(?:5B|5D|24|3A|40|2C)/g;
|
|
@@ -479,6 +524,7 @@ export function serializePrimitiveParam(name, value, options) {
|
|
|
479
524
|
* Serialize object param (shallow only)
|
|
480
525
|
* @type {import("./index.js").serializeObjectParam}
|
|
481
526
|
*/
|
|
527
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
482
528
|
export function serializeObjectParam(name, value, options) {
|
|
483
529
|
if (!value || typeof value !== "object") {
|
|
484
530
|
return "";
|
|
@@ -494,9 +540,13 @@ export function serializeObjectParam(name, value, options) {
|
|
|
494
540
|
// explode: false
|
|
495
541
|
if (options.style !== "deepObject" && options.explode === false) {
|
|
496
542
|
for (const k in value) {
|
|
497
|
-
values.push(
|
|
543
|
+
values.push(
|
|
544
|
+
encodeName(k),
|
|
545
|
+
options.allowReserved === true ? value[k] : encodeURIComponent(value[k]),
|
|
546
|
+
);
|
|
498
547
|
}
|
|
499
|
-
|
|
548
|
+
// note: values are always joined by comma in explode: false (but joiner can prefix)
|
|
549
|
+
const final = values.join(",");
|
|
500
550
|
switch (options.style) {
|
|
501
551
|
case "form": {
|
|
502
552
|
return `${name}=${final}`;
|
|
@@ -526,6 +576,7 @@ export function serializeObjectParam(name, value, options) {
|
|
|
526
576
|
* Serialize array param (shallow only)
|
|
527
577
|
* @type {import("./index.js").serializeArrayParam}
|
|
528
578
|
*/
|
|
579
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
529
580
|
export function serializeArrayParam(name, value, options) {
|
|
530
581
|
if (!Array.isArray(value)) {
|
|
531
582
|
return "";
|
|
@@ -533,8 +584,11 @@ export function serializeArrayParam(name, value, options) {
|
|
|
533
584
|
|
|
534
585
|
// explode: false
|
|
535
586
|
if (options.explode === false) {
|
|
536
|
-
|
|
537
|
-
const
|
|
587
|
+
// note: for arrays, joiners vary wildly based on style + explode behavior
|
|
588
|
+
const joiner = { form: ",", spaceDelimited: "%20", pipeDelimited: "|" }[options.style] || ",";
|
|
589
|
+
const final = (
|
|
590
|
+
options.allowReserved === true ? value : value.map((v) => encodeURIComponent(v))
|
|
591
|
+
).join(joiner);
|
|
538
592
|
switch (options.style) {
|
|
539
593
|
case "simple": {
|
|
540
594
|
return final;
|
|
@@ -619,6 +673,7 @@ export function createQuerySerializer(options) {
|
|
|
619
673
|
* @type {import("./index.js").defaultPathSerializer}
|
|
620
674
|
* @see https://swagger.io/docs/specification/serialization/#path
|
|
621
675
|
*/
|
|
676
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: inherited, kept until 1.1 (ADR-036)
|
|
622
677
|
export function defaultPathSerializer(pathname, pathParams) {
|
|
623
678
|
let nextURL = pathname;
|
|
624
679
|
for (const match of pathname.match(PATH_PARAM_RE) ?? []) {
|
|
@@ -640,24 +695,31 @@ export function defaultPathSerializer(pathname, pathParams) {
|
|
|
640
695
|
continue;
|
|
641
696
|
}
|
|
642
697
|
const value = pathParams[name];
|
|
698
|
+
let serialized;
|
|
643
699
|
if (Array.isArray(value)) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (style === "matrix") {
|
|
652
|
-
nextURL = nextURL.replace(match, `;${serializePrimitiveParam(name, value)}`);
|
|
653
|
-
continue;
|
|
700
|
+
serialized = serializeArrayParam(name, value, { style, explode });
|
|
701
|
+
} else if (typeof value === "object") {
|
|
702
|
+
serialized = serializeObjectParam(name, value, { style, explode });
|
|
703
|
+
} else if (style === "matrix") {
|
|
704
|
+
serialized = `;${serializePrimitiveParam(name, value)}`;
|
|
705
|
+
} else {
|
|
706
|
+
serialized = style === "label" ? `.${encodeURIComponent(value)}` : encodeURIComponent(value);
|
|
654
707
|
}
|
|
655
|
-
|
|
708
|
+
// A function: a string replacement reads `$'`, `$&` and the like, which object keys keep.
|
|
709
|
+
nextURL = nextURL.replace(match, () => serialized);
|
|
656
710
|
}
|
|
657
|
-
|
|
658
|
-
|
|
711
|
+
const path = nextURL.split(QUERY_OR_FRAGMENT_RE, 1)[0];
|
|
712
|
+
// `encodeURIComponent` keeps dots, so a value `..` would move the request up a path level
|
|
713
|
+
// (ADR-013).
|
|
714
|
+
// Only the path is checked: before a query (`/a/{id}?v=2`), `$` did not match after `..`.
|
|
715
|
+
if (DOT_SEGMENT_RE.test(path)) {
|
|
659
716
|
throw new Error(`Dot path segment in ${nextURL}`);
|
|
660
717
|
}
|
|
718
|
+
// `//host/…` is a protocol-relative URL: with a relative baseUrl a browser sends it to that
|
|
719
|
+
// host.
|
|
720
|
+
if (path.startsWith("//")) {
|
|
721
|
+
throw new Error(`Empty first path segment in ${nextURL}`);
|
|
722
|
+
}
|
|
661
723
|
return nextURL;
|
|
662
724
|
}
|
|
663
725
|
|
|
@@ -677,15 +739,24 @@ function isRawBody(body) {
|
|
|
677
739
|
|
|
678
740
|
/**
|
|
679
741
|
* A form body. A nested object has no standard form encoding (servers read `a[b]`, `a.b` or JSON),
|
|
680
|
-
* and `URLSearchParams` wrote it as `[object Object]` (ADR-013, decision 7).
|
|
742
|
+
* and `URLSearchParams` wrote it as `[object Object]` (ADR-013, decision 7). `null` and `undefined`
|
|
743
|
+
* fields are left out, as in a multipart body; `URLSearchParams` sent them as the text "null" and
|
|
744
|
+
* "undefined" (ADR-030). A string or a list of pairs is form data already.
|
|
681
745
|
*/
|
|
682
746
|
function formBody(body) {
|
|
683
|
-
|
|
747
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
748
|
+
return new URLSearchParams(body).toString();
|
|
749
|
+
}
|
|
750
|
+
const form = new URLSearchParams();
|
|
751
|
+
for (const [name, value] of Object.entries(body)) {
|
|
684
752
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
685
753
|
throw new Error(NESTED_FORM_VALUE_MESSAGE);
|
|
686
754
|
}
|
|
755
|
+
if (value != null) {
|
|
756
|
+
form.append(name, value);
|
|
757
|
+
}
|
|
687
758
|
}
|
|
688
|
-
return
|
|
759
|
+
return form.toString();
|
|
689
760
|
}
|
|
690
761
|
|
|
691
762
|
/**
|
|
@@ -718,14 +789,12 @@ export function defaultBodySerializer(body, headers) {
|
|
|
718
789
|
if (isRawBody(body)) {
|
|
719
790
|
return body;
|
|
720
791
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
: undefined;
|
|
728
|
-
if (contentType === FORM_MEDIA_TYPE) {
|
|
792
|
+
// Headers with a `get` are read directly: headers from another realm (iframe, `node:vm`) may have
|
|
793
|
+
// nothing else (upstream #2637). A record or a list of pairs is read through `Headers`, which
|
|
794
|
+
// matches the name in any letter case.
|
|
795
|
+
const contentType =
|
|
796
|
+
headers && (isHeaders(headers) ? headers : mergeHeaders(headers)).get("Content-Type");
|
|
797
|
+
if (FORM_MEDIA_TYPE_RE.test(contentType)) {
|
|
729
798
|
return formBody(body);
|
|
730
799
|
}
|
|
731
800
|
if (body && typeof body === "object" && MULTIPART_MEDIA_TYPE_RE.test(contentType)) {
|
|
@@ -769,7 +838,12 @@ export function mergeHeaders(...allHeaders) {
|
|
|
769
838
|
if (!h || typeof h !== "object") {
|
|
770
839
|
continue;
|
|
771
840
|
}
|
|
772
|
-
|
|
841
|
+
// A list of pairs (`[["x-t", "1"]]`) is a `HeadersInit` too; `Headers` joins a repeated name.
|
|
842
|
+
const iterator = isHeaders(h)
|
|
843
|
+
? h.entries()
|
|
844
|
+
: Symbol.iterator in h
|
|
845
|
+
? new Headers(h).entries()
|
|
846
|
+
: Object.entries(h);
|
|
773
847
|
for (const [k, v] of iterator) {
|
|
774
848
|
if (v === null) {
|
|
775
849
|
finalHeaders.delete(k);
|