Skip to content

Commit f0cda7f

Browse files
authored
Use overridable fetch instead of unidici's request (#1002)
When loading the schema, the `load` function used to only consider the Content-Type header when capitalized. However, the HTTP specification states that headers are to be treated as case-insensitive. HTTP/2 further enforces this by stating that all HTTP headers must be converted to lowercase when encoded. Therefore, many recent implementations move away from the conventional capitalization in favour of headers in lowercase, such as the Nest framework. In issue #998, it is shown that loading the schema from a live server implemented in Nest fails when the endpoint does not include the desired extension. This commit fixes the issue by replacing unidici's request by fetch. The request function returns headers as a record, where the keys are left exactly as the headers are received, meaning that the casing of headers affect the output. Fetch, on the other hand, returns header in a special object whose accessor method `get` will lookup headers in a case-insensitive way. We took this opportunity to make the fetch implementation overridable. This way, it is possible for the caller to swap it out for another. It is step towards portability, as openapiTS does not run as-is in the browser, Deno or Cloudflare Workers at the moment. Additionally, unit tests have been added for the `load` function. Fixes #998.
1 parent a5c62ee commit f0cda7f

File tree

5 files changed

+157
-10
lines changed

5 files changed

+157
-10
lines changed

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import transformParameterObject from "./transform/parameter-object.js";
99
import transformRequestBodyObject from "./transform/request-body-object.js";
1010
import transformResponseObject from "./transform/response-object.js";
1111
import transformSchemaObject from "./transform/schema-object.js";
12-
import { error, escObjKey, getEntries, indent } from "./utils.js";
12+
import { error, escObjKey, getDefaultFetch, getEntries, indent } from "./utils.js";
1313
export * from "./types.js"; // expose all types to consumers
1414

1515
const EMPTY_OBJECT_RE = /^\s*\{?\s*\}?\s*$/;
@@ -67,6 +67,7 @@ async function openapiTS(
6767
urlCache: new Set(),
6868
httpHeaders: options.httpHeaders,
6969
httpMethod: options.httpMethod,
70+
fetch: options.fetch ?? getDefaultFetch(),
7071
});
7172

7273
// 1. basic validation

src/load.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ComponentsObject,
3+
Fetch,
34
GlobalContext,
45
OpenAPI3,
56
OperationObject,
@@ -15,7 +16,7 @@ import path from "node:path";
1516
import { Readable } from "node:stream";
1617
import { URL } from "node:url";
1718
import yaml from "js-yaml";
18-
import { request, type Dispatcher } from "undici";
19+
import type { Dispatcher } from "undici";
1920
import { parseRef, error, makeTSIndex, walk, isRemoteURL, isFilepath } from "./utils.js";
2021

2122
type SchemaMap = { [id: string]: Subschema };
@@ -87,7 +88,7 @@ function parseHttpHeaders(httpHeaders: Record<string, any>): Record<string, any>
8788
return finalHeaders;
8889
}
8990

90-
interface LoadOptions extends GlobalContext {
91+
export interface LoadOptions extends GlobalContext {
9192
/** Subschemas may be any type; this helps transform correctly */
9293
hint?: Subschema["hint"];
9394
auth?: string;
@@ -96,6 +97,7 @@ interface LoadOptions extends GlobalContext {
9697
urlCache: Set<string>;
9798
httpHeaders?: Record<string, any>;
9899
httpMethod?: string;
100+
fetch: Fetch;
99101
}
100102

101103
/** Load a schema from local path or remote URL */
@@ -104,7 +106,6 @@ export default async function load(
104106
options: LoadOptions
105107
): Promise<{ [url: string]: Subschema }> {
106108
let schemaID = ".";
107-
108109
// 1. load contents
109110
// 1a. URL
110111
if (schema instanceof URL) {
@@ -130,19 +131,20 @@ export default async function load(
130131
headers[k] = v;
131132
}
132133
}
133-
const res = await request(schema, { method: (options.httpMethod as Dispatcher.HttpMethod) || "GET", headers });
134-
const contentType = Array.isArray(res.headers["content-type"])
135-
? res.headers["content-type"][0]
136-
: res.headers["content-type"];
134+
const res = await options.fetch(schema, {
135+
method: (options.httpMethod as Dispatcher.HttpMethod) || "GET",
136+
headers,
137+
});
138+
const contentType = res.headers.get("content-type");
137139
if (ext === ".json" || (contentType && contentType.includes("json"))) {
138140
options.schemas[schemaID] = {
139141
hint,
140-
schema: parseJSON(await res.body.text()),
142+
schema: parseJSON(await res.text()),
141143
};
142144
} else if (ext === ".yaml" || ext === ".yml" || (contentType && contentType.includes("yaml"))) {
143145
options.schemas[schemaID] = {
144146
hint,
145-
schema: parseYAML(await res.body.text()),
147+
schema: parseYAML(await res.text()),
146148
};
147149
}
148150
}

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { URL } from "node:url";
22
import type { TransformSchemaObjectOptions } from "./transform/schema-object";
3+
import type { RequestInfo, RequestInit, Response } from "undici";
34

45
export interface Extensable {
56
[key: `x-${string}`]: any;
@@ -608,6 +609,11 @@ export interface OpenAPITSOptions {
608609
commentHeader?: string;
609610
/** (optional) inject code before schema ? */
610611
inject?: string;
612+
/**
613+
* (optional) A fetch implementation. Will default to the global fetch
614+
* function if available; else, it will use unidici's fetch function.
615+
*/
616+
fetch?: Fetch;
611617
}
612618

613619
/** Subschema discriminator (note: only valid $ref types accepted here) */
@@ -637,3 +643,10 @@ export interface GlobalContext {
637643
silent: boolean;
638644
supportArrayLength: boolean;
639645
}
646+
647+
// Fetch is available in the global scope starting with Node v18.
648+
// However, @types/node does not have it yet available.
649+
// GitHub issue: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/60924
650+
// Because node's underlying implementation relies on unidici, it is safe to
651+
// rely on unidici's type until @types/node ships it.
652+
export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;

src/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import c from "ansi-colors";
22
import { isAbsolute } from "node:path";
33
import supportsColor from "supports-color";
4+
import { fetch as unidiciFetch } from "undici";
5+
import { Fetch } from "types";
46

57
if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) c.enabled = false;
68

@@ -239,3 +241,11 @@ export function isRemoteURL(url: string): boolean {
239241
export function isFilepath(url: string): boolean {
240242
return url.startsWith("file://") || isAbsolute(url);
241243
}
244+
245+
export function getDefaultFetch(): Fetch {
246+
const globalFetch: Fetch | undefined = (globalThis as any).fetch;
247+
if (typeof globalFetch === "undefined") {
248+
return unidiciFetch;
249+
}
250+
return globalFetch;
251+
}

test/load.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import internalLoad, { type LoadOptions } from "../src/load.js";
2+
import type { Subschema } from "../src/types";
3+
import { Response, fetch as unidiciFetch } from "undici";
4+
import { Readable } from "node:stream";
5+
import { dump as stringifyYaml } from "js-yaml";
6+
7+
describe("Load", () => {
8+
describe("remote schema", () => {
9+
describe("in json", () => {
10+
test("type deduced from extension .json", async () => {
11+
const output = await load(new URL("https://example.com/openapi.json"), {
12+
async fetch() {
13+
return new Response(JSON.stringify(exampleSchema), {
14+
headers: { "Content-Type": "text/plain" },
15+
});
16+
},
17+
});
18+
expect(output["."].schema).toEqual(exampleSchema);
19+
});
20+
test("type deduced from Content-Type header", async () => {
21+
const output = await load(new URL("https://example.com/openapi"), {
22+
async fetch() {
23+
return new Response(JSON.stringify(exampleSchema), {
24+
headers: { "Content-Type": "application/json" },
25+
});
26+
},
27+
});
28+
expect(output["."].schema).toEqual(exampleSchema);
29+
});
30+
// Regression test for https://github.com/drwpow/openapi-typescript/issues/988
31+
test("type deduced from Content-Type header, in lowercase", async () => {
32+
const output = await load(new URL("https://example.com/openapi"), {
33+
async fetch() {
34+
return new Response(JSON.stringify(exampleSchema), {
35+
headers: { "content-type": "application/json" },
36+
});
37+
},
38+
});
39+
expect(output["."].schema).toEqual(exampleSchema);
40+
});
41+
});
42+
43+
describe("in yaml", () => {
44+
test("type deduced from extension .yaml", async () => {
45+
const output = await load(new URL("https://example.com/openapi.yaml"), {
46+
async fetch() {
47+
return new Response(stringifyYaml(exampleSchema), {
48+
headers: { "Content-Type": "text/plain" },
49+
});
50+
},
51+
});
52+
expect(output["."].schema).toEqual(exampleSchema);
53+
});
54+
test("type deduced from extension .yml", async () => {
55+
const output = await load(new URL("https://example.com/openapi.yml"), {
56+
async fetch() {
57+
return new Response(stringifyYaml(exampleSchema), {
58+
headers: { "Content-Type": "text/plain" },
59+
});
60+
},
61+
});
62+
expect(output["."].schema).toEqual(exampleSchema);
63+
});
64+
test("type deduced from Content-Type header", async () => {
65+
const output = await load(new URL("https://example.com/openapi"), {
66+
async fetch() {
67+
return new Response(stringifyYaml(exampleSchema), {
68+
headers: { "Content-Type": "application/yaml" },
69+
});
70+
},
71+
});
72+
expect(output["."].schema).toEqual(exampleSchema);
73+
});
74+
// Regression test for https://github.com/drwpow/openapi-typescript/issues/988
75+
test("type deduced from Content-Type header, in lowercase", async () => {
76+
const output = await load(new URL("https://example.com/openapi"), {
77+
async fetch() {
78+
return new Response(stringifyYaml(exampleSchema), {
79+
headers: { "content-type": "application/yaml" },
80+
});
81+
},
82+
});
83+
expect(output["."].schema).toEqual(exampleSchema);
84+
});
85+
});
86+
});
87+
});
88+
89+
const exampleSchema = {
90+
openapi: "3.1.0",
91+
paths: {
92+
"/foo": {
93+
get: {},
94+
},
95+
},
96+
};
97+
98+
async function load(
99+
schema: URL | Subschema | Readable,
100+
options?: Partial<LoadOptions>
101+
): Promise<{ [url: string]: Subschema }> {
102+
return internalLoad(schema, {
103+
rootURL: schema as URL,
104+
schemas: {},
105+
urlCache: new Set(),
106+
fetch: vi.fn(unidiciFetch),
107+
additionalProperties: false,
108+
alphabetize: false,
109+
defaultNonNullable: false,
110+
discriminators: {},
111+
immutableTypes: false,
112+
indentLv: 0,
113+
operations: {},
114+
pathParamsAsTypes: false,
115+
postTransform: undefined,
116+
silent: true,
117+
supportArrayLength: false,
118+
transform: undefined,
119+
...options,
120+
});
121+
}

0 commit comments

Comments
 (0)