openapi-typesafe-fetch 0.0.0 → 1.0.0-rc.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/src/index.js ADDED
@@ -0,0 +1,814 @@
1
+ // settings & const
2
+ const PATH_PARAM_RE = /\{[^{}]+\}/g;
3
+ /** A `.` or `..` path segment; URLs resolve it, so a parameter value could leave its path (#2483). */
4
+ const DOT_SEGMENT_RE = /(^|\/)\.\.?(\/|$)/;
5
+ /** Any JSON media type: `application/json`, `application/problem+json`, with or without parameters. */
6
+ const JSON_MEDIA_TYPE_RE = /json/i;
7
+ const FORM_MEDIA_TYPE = "application/x-www-form-urlencoded";
8
+ /** `multipart/form-data`, with or without parameters. */
9
+ const MULTIPART_MEDIA_TYPE_RE = /^multipart\/form-data(;|$)/i;
10
+ const NESTED_FORM_VALUE_MESSAGE = "Nested form values need your own `bodySerializer()`.";
11
+ /** The `boundary` parameter of a multipart `Content-Type`; only the platform knows its value. */
12
+ const BOUNDARY_PARAMETER_RE = /;\s*boundary=/i;
13
+
14
+ /**
15
+ * Whether `fetch` is Node.js undici, which takes `requestInitExt` as a second argument. A Node.js
16
+ * version check is not needed: every supported Node.js (`engines`) ships undici.
17
+ */
18
+ const supportsRequestInitExt = () => {
19
+ return typeof process === "object" && process.versions?.undici;
20
+ };
21
+
22
+ /**
23
+ * The number of request ids handed out. It is shared by all clients on purpose: one middleware can
24
+ * be used by several clients, and it tells their requests apart by id.
25
+ */
26
+ let requestIdCount = 0;
27
+
28
+ /**
29
+ * Returns a cheap, non-cryptographically-secure ID, unique within the process.
30
+ * A counter, not `Math.random()`: Next.js prerendering rejects random values read before uncached
31
+ * data (upstream #2486, #2763). The name stays for compatibility.
32
+ */
33
+ export function randomID() {
34
+ requestIdCount += 1;
35
+ return requestIdCount.toString(36);
36
+ }
37
+
38
+ /**
39
+ * Create an openapi-typesafe-fetch client.
40
+ * @type {import("./index.js").default}
41
+ */
42
+ export default function createClient(clientOptions) {
43
+ let {
44
+ baseUrl = "",
45
+ Request: CustomRequest = globalThis.Request,
46
+ fetch: baseFetch = globalThis.fetch,
47
+ querySerializer: globalQuerySerializer,
48
+ bodySerializer: globalBodySerializer,
49
+ pathSerializer: globalPathSerializer,
50
+ headers: baseHeaders,
51
+ requestInitExt = undefined,
52
+ ...baseOptions
53
+ } = { ...clientOptions };
54
+ requestInitExt = supportsRequestInitExt() ? requestInitExt : undefined;
55
+ baseUrl = removeTrailingSlash(baseUrl);
56
+ const globalMiddlewares = [];
57
+
58
+ /**
59
+ * Per-request fetch (keeps settings created in createClient()
60
+ * @param {T} url
61
+ * @param {import('./index.js').FetchOptions<T>} fetchOptions
62
+ */
63
+ async function coreFetch(schemaPath, fetchOptions) {
64
+ const {
65
+ baseUrl: localBaseUrl,
66
+ fetch = baseFetch,
67
+ Request = CustomRequest,
68
+ headers,
69
+ params = {},
70
+ parseAs = "json",
71
+ querySerializer: requestQuerySerializer,
72
+ bodySerializer = globalBodySerializer ?? defaultBodySerializer,
73
+ pathSerializer: requestPathSerializer,
74
+ body,
75
+ middleware: requestMiddlewares = [],
76
+ ...init
77
+ } = fetchOptions || {};
78
+ let finalBaseUrl = baseUrl;
79
+ if (localBaseUrl) {
80
+ finalBaseUrl = removeTrailingSlash(localBaseUrl) ?? baseUrl;
81
+ }
82
+
83
+ let querySerializer =
84
+ typeof globalQuerySerializer === "function"
85
+ ? globalQuerySerializer
86
+ : createQuerySerializer(globalQuerySerializer);
87
+ if (requestQuerySerializer) {
88
+ querySerializer =
89
+ typeof requestQuerySerializer === "function"
90
+ ? requestQuerySerializer
91
+ : createQuerySerializer({
92
+ ...(typeof globalQuerySerializer === "object" ? globalQuerySerializer : {}),
93
+ ...requestQuerySerializer,
94
+ });
95
+ }
96
+
97
+ const pathSerializer = requestPathSerializer || globalPathSerializer || defaultPathSerializer;
98
+
99
+ const serializedBody =
100
+ body === undefined
101
+ ? undefined
102
+ : bodySerializer(
103
+ body,
104
+ // Note: we declare mergeHeaders() both here and below because it’s a bit of a chicken-or-egg situation:
105
+ // bodySerializer() needs all headers so we aren’t dropping ones set by the user, however,
106
+ // the result of this ALSO sets the lowest-priority content-type header. So we re-merge below,
107
+ // setting the content-type at the very beginning to be overwritten.
108
+ // Lastly, based on the way headers work, it’s not a simple “present-or-not” check becauase null intentionally un-sets headers.
109
+ mergeHeaders(baseHeaders, headers, params.header),
110
+ );
111
+ const finalHeaders = mergeHeaders(
112
+ // with no body, we should not to set Content-Type
113
+ serializedBody === undefined ||
114
+ // a raw body (FormData, Blob, ...) gets its Content-Type from the platform, or none
115
+ isRawBody(serializedBody)
116
+ ? {}
117
+ : {
118
+ "Content-Type": "application/json",
119
+ },
120
+ baseHeaders,
121
+ headers,
122
+ params.header,
123
+ );
124
+ // `fetch` sends a FormData as multipart with a boundary only it knows. A `Content-Type` without
125
+ // that boundary, set by hand, replaced the platform's, and the server could not split the parts
126
+ // (upstream #1214), so it goes and the platform sets its own (ADR-013, decision 10).
127
+ if (serializedBody instanceof FormData && !BOUNDARY_PARAMETER_RE.test(finalHeaders.get("Content-Type"))) {
128
+ finalHeaders.delete("Content-Type");
129
+ }
130
+
131
+ // Client level middleware take priority over request-level middleware
132
+ const finalMiddlewares = [...globalMiddlewares, ...requestMiddlewares];
133
+
134
+ const requestInit = {
135
+ redirect: "follow",
136
+ ...baseOptions,
137
+ ...init,
138
+ body: serializedBody,
139
+ headers: finalHeaders,
140
+ };
141
+
142
+ let id;
143
+ let options;
144
+ let request = new Request(
145
+ createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer, pathSerializer }),
146
+ requestInit,
147
+ );
148
+ let response;
149
+
150
+ /** Add custom parameters to Request object */
151
+ for (const key in init) {
152
+ if (!(key in request)) {
153
+ request[key] = init[key];
154
+ }
155
+ }
156
+
157
+ if (finalMiddlewares.length) {
158
+ id = randomID();
159
+
160
+ // middleware (request)
161
+ options = Object.freeze({
162
+ baseUrl: finalBaseUrl,
163
+ fetch,
164
+ parseAs,
165
+ querySerializer,
166
+ bodySerializer,
167
+ pathSerializer,
168
+ });
169
+ for (const m of finalMiddlewares) {
170
+ if (m && typeof m === "object" && typeof m.onRequest === "function") {
171
+ const result = await m.onRequest({
172
+ request,
173
+ schemaPath,
174
+ params,
175
+ options,
176
+ id,
177
+ });
178
+ if (result) {
179
+ if (isRequest(result)) {
180
+ request = result;
181
+ } else if (isResponse(result)) {
182
+ response = result;
183
+ break;
184
+ } else {
185
+ throw new Error("onRequest: must return new Request() or Response() when modifying the request");
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ if (!response) {
193
+ // fetch!
194
+ try {
195
+ response = await fetch(request, requestInitExt);
196
+ } catch (error) {
197
+ let errorAfterMiddleware = error;
198
+ // middleware (error)
199
+ // execute in reverse-array order (first priority gets last transform)
200
+ if (finalMiddlewares.length) {
201
+ for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
202
+ const m = finalMiddlewares[i];
203
+ if (m && typeof m === "object" && typeof m.onError === "function") {
204
+ const result = await m.onError({
205
+ request,
206
+ error: errorAfterMiddleware,
207
+ schemaPath,
208
+ params,
209
+ options,
210
+ id,
211
+ });
212
+ if (result) {
213
+ // if error is handled by returning a response, skip remaining middleware
214
+ if (isResponse(result)) {
215
+ errorAfterMiddleware = undefined;
216
+ response = result;
217
+ break;
218
+ }
219
+
220
+ if (result instanceof Error) {
221
+ errorAfterMiddleware = result;
222
+ continue;
223
+ }
224
+
225
+ throw new Error("onError: must return new Response() or instance of Error");
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ // rethrow error if not handled by middleware
232
+ if (errorAfterMiddleware) {
233
+ throw errorAfterMiddleware;
234
+ }
235
+ }
236
+
237
+ // middleware (response)
238
+ // execute in reverse-array order (first priority gets last transform)
239
+ if (finalMiddlewares.length) {
240
+ for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
241
+ const m = finalMiddlewares[i];
242
+ if (m && typeof m === "object" && typeof m.onResponse === "function") {
243
+ const result = await m.onResponse({
244
+ request,
245
+ response,
246
+ schemaPath,
247
+ params,
248
+ options,
249
+ id,
250
+ });
251
+ if (result) {
252
+ if (!isResponse(result)) {
253
+ throw new Error("onResponse: must return new Response() when modifying the response");
254
+ }
255
+ response = result;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ const contentLength = response.headers.get("Content-Length");
263
+ // handle empty content
264
+ if (
265
+ response.status === 204 ||
266
+ request.method === "HEAD" ||
267
+ (contentLength === "0" && !response.headers.get("Transfer-Encoding")?.includes("chunked"))
268
+ ) {
269
+ return response.ok ? { data: undefined, response } : { error: undefined, response };
270
+ }
271
+
272
+ // parse response (falling back to .text() when necessary)
273
+ if (response.ok) {
274
+ const getResponseData = async () => {
275
+ // if "stream", skip parsing entirely
276
+ if (parseAs === "stream") {
277
+ return response.body;
278
+ }
279
+
280
+ if (parseAs === "json" && !contentLength) {
281
+ // use text() when no content-length is provided to avoid errors parsing empty bodies (200 with no content)
282
+ const raw = await response.text();
283
+ return raw ? JSON.parse(raw) : undefined;
284
+ }
285
+
286
+ return await response[parseAs]();
287
+ };
288
+ return { data: await getResponseData(), response };
289
+ }
290
+
291
+ // handle errors (use text() when no content-length to safely handle empty bodies from proxies)
292
+ const raw = await response.text();
293
+ if (!raw) {
294
+ // empty error body - return undefined to be consistent with status 204 handling
295
+ return { error: undefined, response };
296
+ }
297
+ let error = raw;
298
+ try {
299
+ error = JSON.parse(raw); // attempt to parse as JSON
300
+ } catch {
301
+ // noop - keep as raw text
302
+ }
303
+ return { error, response };
304
+ }
305
+
306
+ return {
307
+ request(method, url, init) {
308
+ return coreFetch(url, { ...init, method: method.toUpperCase() });
309
+ },
310
+ /** Call a GET endpoint */
311
+ GET(url, init) {
312
+ return coreFetch(url, { ...init, method: "GET" });
313
+ },
314
+ /** Call a PUT endpoint */
315
+ PUT(url, init) {
316
+ return coreFetch(url, { ...init, method: "PUT" });
317
+ },
318
+ /** Call a POST endpoint */
319
+ POST(url, init) {
320
+ return coreFetch(url, { ...init, method: "POST" });
321
+ },
322
+ /** Call a DELETE endpoint */
323
+ DELETE(url, init) {
324
+ return coreFetch(url, { ...init, method: "DELETE" });
325
+ },
326
+ /** Call a OPTIONS endpoint */
327
+ OPTIONS(url, init) {
328
+ return coreFetch(url, { ...init, method: "OPTIONS" });
329
+ },
330
+ /** Call a HEAD endpoint */
331
+ HEAD(url, init) {
332
+ return coreFetch(url, { ...init, method: "HEAD" });
333
+ },
334
+ /** Call a PATCH endpoint */
335
+ PATCH(url, init) {
336
+ return coreFetch(url, { ...init, method: "PATCH" });
337
+ },
338
+ /** Call a TRACE endpoint */
339
+ TRACE(url, init) {
340
+ return coreFetch(url, { ...init, method: "TRACE" });
341
+ },
342
+ /** Call a QUERY endpoint (OpenAPI 3.2) */
343
+ QUERY(url, init) {
344
+ return coreFetch(url, { ...init, method: "QUERY" });
345
+ },
346
+ /** Register middleware */
347
+ use(...middleware) {
348
+ for (const m of middleware) {
349
+ if (!m) {
350
+ continue;
351
+ }
352
+ if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) {
353
+ throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse()` or `onError()`");
354
+ }
355
+ globalMiddlewares.push(m);
356
+ }
357
+ },
358
+ /** Unregister middleware */
359
+ eject(...middleware) {
360
+ for (const m of middleware) {
361
+ const i = globalMiddlewares.indexOf(m);
362
+ if (i !== -1) {
363
+ globalMiddlewares.splice(i, 1);
364
+ }
365
+ }
366
+ },
367
+ };
368
+ }
369
+
370
+ class PathCallForwarder {
371
+ constructor(client, url) {
372
+ this.client = client;
373
+ this.url = url;
374
+ }
375
+
376
+ GET = (init) => {
377
+ return this.client.GET(this.url, init);
378
+ };
379
+ PUT = (init) => {
380
+ return this.client.PUT(this.url, init);
381
+ };
382
+ POST = (init) => {
383
+ return this.client.POST(this.url, init);
384
+ };
385
+ DELETE = (init) => {
386
+ return this.client.DELETE(this.url, init);
387
+ };
388
+ OPTIONS = (init) => {
389
+ return this.client.OPTIONS(this.url, init);
390
+ };
391
+ HEAD = (init) => {
392
+ return this.client.HEAD(this.url, init);
393
+ };
394
+ PATCH = (init) => {
395
+ return this.client.PATCH(this.url, init);
396
+ };
397
+ TRACE = (init) => {
398
+ return this.client.TRACE(this.url, init);
399
+ };
400
+ QUERY = (init) => {
401
+ return this.client.QUERY(this.url, init);
402
+ };
403
+ }
404
+
405
+ class PathClientProxyHandler {
406
+ constructor() {
407
+ this.client = null;
408
+ }
409
+
410
+ // Assume the property is an URL.
411
+ get(coreClient, url) {
412
+ const forwarder = new PathCallForwarder(coreClient, url);
413
+ this.client[url] = forwarder;
414
+ return forwarder;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Wrap openapi-typesafe-fetch client to support a path based API.
420
+ * @type {import("./index.js").wrapAsPathBasedClient}
421
+ */
422
+ export function wrapAsPathBasedClient(coreClient) {
423
+ const handler = new PathClientProxyHandler();
424
+ const proxy = new Proxy(coreClient, handler);
425
+
426
+ // Put the proxy on the prototype chain of the actual client.
427
+ // This means if we do not have a memoized PathCallForwarder,
428
+ // we fall back to the proxy to synthesize it.
429
+ // However, the proxy itself is not on the hot-path (if we fetch the same
430
+ // endpoint multiple times, only the first call will hit the proxy).
431
+ function Client() {}
432
+ Client.prototype = proxy;
433
+
434
+ const client = new Client();
435
+
436
+ // Feed the client back to the proxy handler so it can store the generated
437
+ // PathCallForwarder.
438
+ handler.client = client;
439
+
440
+ return client;
441
+ }
442
+
443
+ /**
444
+ * Convenience method to an openapi-typesafe-fetch path based client.
445
+ * Strictly equivalent to `wrapAsPathBasedClient(createClient(...))`.
446
+ * @type {import("./index.js").createPathBasedClient}
447
+ */
448
+ export function createPathBasedClient(clientOptions) {
449
+ return wrapAsPathBasedClient(createClient(clientOptions));
450
+ }
451
+
452
+ // utils
453
+
454
+ // Fetch API objects are recognized by the members the client uses, not by `instanceof`: that is
455
+ // bound to one realm's class, and a Request from the `undici` package, another realm (an iframe,
456
+ // an Electron host) or a custom `Request` option is not an instance of it (upstream #1847). The
457
+ // same rule as for headers in `defaultBodySerializer` (upstream #2637).
458
+
459
+ function isHeaders(value) {
460
+ return typeof value?.get === "function";
461
+ }
462
+
463
+ function isRequest(value) {
464
+ return typeof value?.method === "string" && typeof value.url === "string" && isHeaders(value.headers);
465
+ }
466
+
467
+ function isResponse(value) {
468
+ return typeof value?.status === "number" && isHeaders(value.headers);
469
+ }
470
+
471
+ // Parameter names and object keys can be data (an `additionalProperties` object). They are encoded
472
+ // like values, except for characters common in names that do not split a URL (ADR-013, decision 9).
473
+ const KEPT_NAME_ESCAPES = /%(?:5B|5D|24|3A|40|2C)/g;
474
+
475
+ function encodeName(name) {
476
+ return encodeURIComponent(name).replace(KEPT_NAME_ESCAPES, decodeURIComponent);
477
+ }
478
+
479
+ /**
480
+ * Serialize primitive param values
481
+ * @type {import("./index.js").serializePrimitiveParam}
482
+ */
483
+ export function serializePrimitiveParam(name, value, options) {
484
+ if (value === undefined || value === null) {
485
+ return "";
486
+ }
487
+ if (typeof value === "object") {
488
+ throw new Error(
489
+ "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
490
+ );
491
+ }
492
+ return `${name}=${options?.allowReserved === true ? value : encodeURIComponent(value)}`;
493
+ }
494
+
495
+ /**
496
+ * Serialize object param (shallow only)
497
+ * @type {import("./index.js").serializeObjectParam}
498
+ */
499
+ export function serializeObjectParam(name, value, options) {
500
+ if (!value || typeof value !== "object") {
501
+ return "";
502
+ }
503
+ const values = [];
504
+ const joiner =
505
+ {
506
+ simple: ",",
507
+ label: ".",
508
+ matrix: ";",
509
+ }[options.style] || "&";
510
+
511
+ // explode: false
512
+ if (options.style !== "deepObject" && options.explode === false) {
513
+ for (const k in value) {
514
+ values.push(encodeName(k), options.allowReserved === true ? value[k] : encodeURIComponent(value[k]));
515
+ }
516
+ const final = values.join(","); // note: values are always joined by comma in explode: false (but joiner can prefix)
517
+ switch (options.style) {
518
+ case "form": {
519
+ return `${name}=${final}`;
520
+ }
521
+ case "label": {
522
+ return `.${final}`;
523
+ }
524
+ case "matrix": {
525
+ return `;${name}=${final}`;
526
+ }
527
+ default: {
528
+ return final;
529
+ }
530
+ }
531
+ }
532
+
533
+ // explode: true
534
+ for (const k in value) {
535
+ const finalName = options.style === "deepObject" ? `${name}[${encodeName(k)}]` : encodeName(k);
536
+ values.push(serializePrimitiveParam(finalName, value[k], options));
537
+ }
538
+ const final = values.join(joiner);
539
+ return options.style === "label" || options.style === "matrix" ? `${joiner}${final}` : final;
540
+ }
541
+
542
+ /**
543
+ * Serialize array param (shallow only)
544
+ * @type {import("./index.js").serializeArrayParam}
545
+ */
546
+ export function serializeArrayParam(name, value, options) {
547
+ if (!Array.isArray(value)) {
548
+ return "";
549
+ }
550
+
551
+ // explode: false
552
+ if (options.explode === false) {
553
+ const joiner = { form: ",", spaceDelimited: "%20", pipeDelimited: "|" }[options.style] || ","; // note: for arrays, joiners vary wildly based on style + explode behavior
554
+ const final = (options.allowReserved === true ? value : value.map((v) => encodeURIComponent(v))).join(joiner);
555
+ switch (options.style) {
556
+ case "simple": {
557
+ return final;
558
+ }
559
+ case "label": {
560
+ return `.${final}`;
561
+ }
562
+ case "matrix": {
563
+ return `;${name}=${final}`;
564
+ }
565
+ // case "spaceDelimited":
566
+ // case "pipeDelimited":
567
+ default: {
568
+ return `${name}=${final}`;
569
+ }
570
+ }
571
+ }
572
+
573
+ // explode: true
574
+ const joiner = { simple: ",", label: ".", matrix: ";" }[options.style] || "&";
575
+ const values = [];
576
+ for (const v of value) {
577
+ if (options.style === "simple" || options.style === "label") {
578
+ values.push(options.allowReserved === true ? v : encodeURIComponent(v));
579
+ } else {
580
+ values.push(serializePrimitiveParam(name, v, options));
581
+ }
582
+ }
583
+ return options.style === "label" || options.style === "matrix"
584
+ ? `${joiner}${values.join(joiner)}`
585
+ : values.join(joiner);
586
+ }
587
+
588
+ /**
589
+ * Serialize query params to string
590
+ * @type {import("./index.js").createQuerySerializer}
591
+ */
592
+ export function createQuerySerializer(options) {
593
+ return function querySerializer(queryParams) {
594
+ const search = [];
595
+ if (queryParams && typeof queryParams === "object") {
596
+ for (const key in queryParams) {
597
+ const value = queryParams[key];
598
+ const name = encodeName(key);
599
+ if (value === undefined || value === null) {
600
+ continue;
601
+ }
602
+ if (Array.isArray(value)) {
603
+ if (value.length === 0) {
604
+ continue;
605
+ }
606
+ search.push(
607
+ serializeArrayParam(name, value, {
608
+ style: "form",
609
+ explode: true,
610
+ ...options?.array,
611
+ allowReserved: options?.allowReserved || false,
612
+ }),
613
+ );
614
+ continue;
615
+ }
616
+ if (typeof value === "object") {
617
+ search.push(
618
+ serializeObjectParam(name, value, {
619
+ style: "deepObject",
620
+ explode: true,
621
+ ...options?.object,
622
+ allowReserved: options?.allowReserved || false,
623
+ }),
624
+ );
625
+ continue;
626
+ }
627
+ search.push(serializePrimitiveParam(name, value, options));
628
+ }
629
+ }
630
+ return search.join("&");
631
+ };
632
+ }
633
+
634
+ /**
635
+ * Handle different OpenAPI 3.x serialization styles
636
+ * @type {import("./index.js").defaultPathSerializer}
637
+ * @see https://swagger.io/docs/specification/serialization/#path
638
+ */
639
+ export function defaultPathSerializer(pathname, pathParams) {
640
+ let nextURL = pathname;
641
+ for (const match of pathname.match(PATH_PARAM_RE) ?? []) {
642
+ let name = match.substring(1, match.length - 1);
643
+ let explode = false;
644
+ let style = "simple";
645
+ if (name.endsWith("*")) {
646
+ explode = true;
647
+ name = name.substring(0, name.length - 1);
648
+ }
649
+ if (name.startsWith(".")) {
650
+ style = "label";
651
+ name = name.substring(1);
652
+ } else if (name.startsWith(";")) {
653
+ style = "matrix";
654
+ name = name.substring(1);
655
+ }
656
+ if (!pathParams || pathParams[name] === undefined || pathParams[name] === null) {
657
+ continue;
658
+ }
659
+ const value = pathParams[name];
660
+ if (Array.isArray(value)) {
661
+ nextURL = nextURL.replace(match, serializeArrayParam(name, value, { style, explode }));
662
+ continue;
663
+ }
664
+ if (typeof value === "object") {
665
+ nextURL = nextURL.replace(match, serializeObjectParam(name, value, { style, explode }));
666
+ continue;
667
+ }
668
+ if (style === "matrix") {
669
+ nextURL = nextURL.replace(match, `;${serializePrimitiveParam(name, value)}`);
670
+ continue;
671
+ }
672
+ nextURL = nextURL.replace(match, style === "label" ? `.${encodeURIComponent(value)}` : encodeURIComponent(value));
673
+ }
674
+ // `encodeURIComponent` keeps dots, so a value `..` would move the request up a path level (ADR-013).
675
+ if (DOT_SEGMENT_RE.test(nextURL)) {
676
+ throw new Error(`Dot path segment in ${nextURL}`);
677
+ }
678
+ return nextURL;
679
+ }
680
+
681
+ /**
682
+ * Bodies `fetch` sends as they are, with their own `Content-Type` (FormData, Blob, URLSearchParams)
683
+ * or none. JSON-encoding a Blob or an ArrayBuffer sent `{}` (ADR-013, decision 6).
684
+ */
685
+ function isRawBody(body) {
686
+ return (
687
+ body instanceof FormData ||
688
+ body instanceof Blob ||
689
+ body instanceof URLSearchParams ||
690
+ body instanceof ArrayBuffer ||
691
+ ArrayBuffer.isView(body)
692
+ );
693
+ }
694
+
695
+ /**
696
+ * A form body. A nested object has no standard form encoding (servers read `a[b]`, `a.b` or JSON),
697
+ * and `URLSearchParams` wrote it as `[object Object]` (ADR-013, decision 7).
698
+ */
699
+ function formBody(body) {
700
+ for (const value of Object.values(body)) {
701
+ if (value && typeof value === "object" && !Array.isArray(value)) {
702
+ throw new Error(NESTED_FORM_VALUE_MESSAGE);
703
+ }
704
+ }
705
+ return new URLSearchParams(body).toString();
706
+ }
707
+
708
+ /**
709
+ * A `multipart/form-data` body: one part per field, one per item of an array. A `Blob` (or `File`)
710
+ * is a file part, other primitives are text, `null` and `undefined` are left out. A nested object
711
+ * would need a part content type such as JSON, which the client does not guess (ADR-013,
712
+ * decision 10).
713
+ */
714
+ function multipartBody(body) {
715
+ const formData = new FormData();
716
+ for (const [name, value] of Object.entries(body)) {
717
+ for (const item of [value].flat()) {
718
+ if (item instanceof Blob) {
719
+ formData.append(name, item);
720
+ } else if (item && typeof item === "object") {
721
+ throw new Error(NESTED_FORM_VALUE_MESSAGE);
722
+ } else if (item != null) {
723
+ formData.append(name, String(item));
724
+ }
725
+ }
726
+ }
727
+ return formData;
728
+ }
729
+
730
+ /**
731
+ * Serialize body object to string
732
+ * @type {import("./index.js").defaultBodySerializer}
733
+ */
734
+ export function defaultBodySerializer(body, headers) {
735
+ if (isRawBody(body)) {
736
+ return body;
737
+ }
738
+ const contentType = headers
739
+ ? // `typeof`, not `instanceof Function`: headers from another realm (iframe, `node:vm`) have a
740
+ // `get` that is not an instance of this realm's Function (upstream #2637).
741
+ typeof headers.get === "function"
742
+ ? (headers.get("Content-Type") ?? headers.get("content-type"))
743
+ : (headers["Content-Type"] ?? headers["content-type"])
744
+ : undefined;
745
+ if (contentType === FORM_MEDIA_TYPE) {
746
+ return formBody(body);
747
+ }
748
+ if (body && typeof body === "object" && MULTIPART_MEDIA_TYPE_RE.test(contentType)) {
749
+ return multipartBody(body);
750
+ }
751
+ // A string for a non-JSON media type (`text/plain`, ...) is the body itself; JSON-encoding it
752
+ // added quotes (upstream #2555, #2382). For JSON, a string is a JSON string value (ADR-013).
753
+ if (typeof body === "string" && contentType && !JSON_MEDIA_TYPE_RE.test(contentType)) {
754
+ return body;
755
+ }
756
+ return JSON.stringify(body);
757
+ }
758
+
759
+ /**
760
+ * Construct URL string from baseUrl and handle path and query params
761
+ * @type {import("./index.js").createFinalURL}
762
+ */
763
+ export function createFinalURL(pathname, options) {
764
+ let finalURL = `${options.baseUrl}${pathname}`;
765
+ if (options.params?.path) {
766
+ finalURL = options.pathSerializer(finalURL, options.params.path);
767
+ }
768
+ let search = options.querySerializer(options.params.query ?? {});
769
+ if (search.startsWith("?")) {
770
+ search = search.substring(1);
771
+ }
772
+ if (search) {
773
+ // A path may already have a query string.
774
+ finalURL += `${finalURL.includes("?") ? "&" : "?"}${search}`;
775
+ }
776
+ return finalURL;
777
+ }
778
+
779
+ /**
780
+ * Merge headers a and b, with b taking priority
781
+ * @type {import("./index.js").mergeHeaders}
782
+ */
783
+ export function mergeHeaders(...allHeaders) {
784
+ const finalHeaders = new Headers();
785
+ for (const h of allHeaders) {
786
+ if (!h || typeof h !== "object") {
787
+ continue;
788
+ }
789
+ const iterator = isHeaders(h) ? h.entries() : Object.entries(h);
790
+ for (const [k, v] of iterator) {
791
+ if (v === null) {
792
+ finalHeaders.delete(k);
793
+ } else if (Array.isArray(v)) {
794
+ for (const v2 of v) {
795
+ finalHeaders.append(k, v2);
796
+ }
797
+ } else if (v !== undefined) {
798
+ finalHeaders.set(k, v);
799
+ }
800
+ }
801
+ }
802
+ return finalHeaders;
803
+ }
804
+
805
+ /**
806
+ * Remove trailing slash from url
807
+ * @type {import("./index.js").removeTrailingSlash}
808
+ */
809
+ export function removeTrailingSlash(url) {
810
+ if (url.endsWith("/")) {
811
+ return url.substring(0, url.length - 1);
812
+ }
813
+ return url;
814
+ }