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/src/index.js CHANGED
@@ -1,10 +1,18 @@
1
1
  // settings & const
2
2
  const PATH_PARAM_RE = /\{[^{}]+\}/g;
3
- /** A `.` or `..` path segment; URLs resolve it, so a parameter value could leave its path (#2483). */
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
- /** Any JSON media type: `application/json`, `application/problem+json`, with or without parameters. */
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
- const FORM_MEDIA_TYPE = "application/x-www-form-urlencoded";
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 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.
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 (serializedBody instanceof FormData && !BOUNDARY_PARAMETER_RE.test(finalHeaders.get("Content-Type"))) {
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, { baseUrl: finalBaseUrl, params, querySerializer, pathSerializer }),
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 instanceof Request) {
198
+ if (isRequest(result)) {
180
199
  request = result;
181
- } else if (result instanceof Response) {
200
+ } else if (isResponse(result)) {
182
201
  response = result;
183
202
  break;
184
203
  } else {
185
- throw new Error("onRequest: must return new Request() or Response() when modifying the request");
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 instanceof Response) {
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 instanceof Response)) {
253
- throw new Error("onResponse: must return new Response() when modifying the response");
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 (200 with no content)
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("Middleware must be an object with one of `onRequest()`, `onResponse()` or `onError()`");
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(encodeName(k), options.allowReserved === true ? value[k] : encodeURIComponent(value[k]));
543
+ values.push(
544
+ encodeName(k),
545
+ options.allowReserved === true ? value[k] : encodeURIComponent(value[k]),
546
+ );
498
547
  }
499
- const final = values.join(","); // note: values are always joined by comma in explode: false (but joiner can prefix)
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
- const joiner = { form: ",", spaceDelimited: "%20", pipeDelimited: "|" }[options.style] || ","; // note: for arrays, joiners vary wildly based on style + explode behavior
537
- const final = (options.allowReserved === true ? value : value.map((v) => encodeURIComponent(v))).join(joiner);
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
- nextURL = nextURL.replace(match, serializeArrayParam(name, value, { style, explode }));
645
- continue;
646
- }
647
- if (typeof value === "object") {
648
- nextURL = nextURL.replace(match, serializeObjectParam(name, value, { style, explode }));
649
- continue;
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
- nextURL = nextURL.replace(match, style === "label" ? `.${encodeURIComponent(value)}` : encodeURIComponent(value));
708
+ // A function: a string replacement reads `$'`, `$&` and the like, which object keys keep.
709
+ nextURL = nextURL.replace(match, () => serialized);
656
710
  }
657
- // `encodeURIComponent` keeps dots, so a value `..` would move the request up a path level (ADR-013).
658
- if (DOT_SEGMENT_RE.test(nextURL)) {
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
- for (const value of Object.values(body)) {
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 new URLSearchParams(body).toString();
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
- const contentType = headers
722
- ? // `typeof`, not `instanceof Function`: headers from another realm (iframe, `node:vm`) have a
723
- // `get` that is not an instance of this realm's Function (upstream #2637).
724
- typeof headers.get === "function"
725
- ? (headers.get("Content-Type") ?? headers.get("content-type"))
726
- : (headers["Content-Type"] ?? headers["content-type"])
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
- const iterator = h instanceof Headers ? h.entries() : Object.entries(h);
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);