Skip to content

Commit 5ff7452

Browse files
committed
Fix bugs with remote $refs, add cwd option for JSON schema parsing
1 parent 17bc87e commit 5ff7452

File tree

12 files changed

+161
-54
lines changed

12 files changed

+161
-54
lines changed

.changeset/silly-maps-rush.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": patch
3+
---
4+
5+
Fix bugs with remote $refs, add `cwd` option for JSON schema parsing

docs/src/content/docs/node.md

+7-6
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ const output = await openapiTS("https://myurl.com/v1/openapi.yaml");
4343
4444
The Node API supports all the [CLI flags](/cli#options) in `camelCase` format, plus the following additional options:
4545
46-
| Name | Type | Default | Description |
47-
| :-------------- | :--------: | :------ | :------------------------------------------------------------------------------- |
48-
| `commentHeader` | `string` | | Override the default “This file was auto-generated …” file heading |
49-
| `inject` | `string` | | Inject arbitrary TypeScript types into the start of the file |
50-
| `transform` | `Function` | | Override the default Schema Object ➝ TypeScript transformer in certain scenarios |
51-
| `postTransform` | `Function` | | Same as `transform` but runs _after_ the TypeScript transformation |
46+
| Name | Type | Default | Description |
47+
| :-------------- | :-------------: | :------ | :------------------------------------------------------------------------------------------------------------------- |
48+
| `commentHeader` | `string` | | Override the default “This file was auto-generated …” file heading |
49+
| `inject` | `string` | | Inject arbitrary TypeScript types into the start of the file |
50+
| `transform` | `Function` | | Override the default Schema Object ➝ TypeScript transformer in certain scenarios |
51+
| `postTransform` | `Function` | | Same as `transform` but runs _after_ the TypeScript transformation |
52+
| `cwd` | `string \| URL` | | (optional) Provide the current working directory to resolve remote `$ref`s (only needed for in-memory JSON objects). |
5253
5354
### transform / postTransform
5455

packages/openapi-typescript/src/index.ts

+27-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GlobalContext, OpenAPI3, OpenAPITSOptions, SchemaObject, Subschema } from "./types.js";
1+
import type { GlobalContext, OpenAPI3, OpenAPITSOptions, ParameterObject, SchemaObject, Subschema } from "./types.js";
22
import type { Readable } from "node:stream";
33
import { URL } from "node:url";
44
import load, { resolveSchema, VIRTUAL_JSON_URL } from "./load.js";
@@ -40,6 +40,7 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op
4040
const ctx: GlobalContext = {
4141
additionalProperties: options.additionalProperties ?? false,
4242
alphabetize: options.alphabetize ?? false,
43+
cwd: options.cwd ?? new URL(`file://${process.cwd()}/`),
4344
defaultNonNullable: options.defaultNonNullable ?? false,
4445
discriminators: {},
4546
transform: typeof options.transform === "function" ? options.transform : undefined,
@@ -55,17 +56,31 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op
5556
excludeDeprecated: options.excludeDeprecated ?? false,
5657
};
5758

58-
// note: we may be loading many large schemas into memory at once; take care to reuse references without cloning
59-
const isInlineSchema = typeof schema !== "string" && schema instanceof URL === false; // eslint-disable-line @typescript-eslint/no-unnecessary-boolean-literal-compare
60-
6159
// 1. load schema (and subschemas)
6260
const allSchemas: { [id: string]: Subschema } = {};
6361
const schemaURL: URL = typeof schema === "string" ? resolveSchema(schema) : (schema as URL);
62+
let rootURL: URL = schemaURL;
63+
64+
// 1a. if passed as in-memory JSON, handle `cwd` option
65+
const isInlineSchema = typeof schema !== "string" && schema instanceof URL === false; // eslint-disable-line @typescript-eslint/no-unnecessary-boolean-literal-compare
66+
if (isInlineSchema) {
67+
if (ctx.cwd) {
68+
if (ctx.cwd instanceof URL) {
69+
rootURL = ctx.cwd;
70+
} else if (typeof ctx.cwd === "string") {
71+
rootURL = new URL(ctx.cwd, `file://${process.cwd()}/`);
72+
}
73+
rootURL = new URL("root.yaml", rootURL); // give the root schema an arbitrary filename ("root.yaml")
74+
} else {
75+
rootURL = new URL(VIRTUAL_JSON_URL); // otherwise, set virtual filename (which prevents resolutions)
76+
}
77+
}
78+
6479
await load(schemaURL, {
6580
...ctx,
6681
auth: options.auth,
6782
schemas: allSchemas,
68-
rootURL: isInlineSchema ? new URL(VIRTUAL_JSON_URL) : schemaURL, // if an inline schema is passed, use virtual URL
83+
rootURL,
6984
urlCache: new Set(),
7085
httpHeaders: options.httpHeaders,
7186
httpMethod: options.httpMethod,
@@ -185,7 +200,7 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op
185200
const c = getSchemaObjectComment(schemaObject as SchemaObject, indentLv);
186201
if (c) subschemaOutput += indent(c, indentLv);
187202

188-
// This might be a Path Item Object; only way to test is if top-level contains a method (not allowed on Schema Object)
203+
// Test for Path Item Object
189204
if (!("type" in schemaObject) && !("$ref" in schemaObject)) {
190205
for (const method of ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as Method[]) {
191206
if (method in schemaObject) {
@@ -194,6 +209,11 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op
194209
}
195210
}
196211
}
212+
// Test for Parameter
213+
if ("in" in schemaObject) {
214+
subschemaOutput += indent(`${escObjKey(name)}: ${transformParameterObject(schemaObject as ParameterObject, { path: `${path}${name}`, ctx: { ...ctx, indentLv } })};\n`, indentLv);
215+
continue;
216+
}
197217

198218
// Otherwise, this is a Schema Object
199219
subschemaOutput += indent(`${escObjKey(name)}: ${transformSchemaObject(schemaObject, { path: `${path}${name}`, ctx: { ...ctx, indentLv } })};\n`, indentLv);
@@ -204,7 +224,7 @@ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: Op
204224
break;
205225
}
206226
case "SchemaObject": {
207-
subschemaOutput = transformSchemaObject(subschema.schema, { path, ctx: { ...ctx, indentLv } });
227+
subschemaOutput = `${transformSchemaObject(subschema.schema, { path, ctx: { ...ctx, indentLv } })};`;
208228
break;
209229
}
210230
default: {

packages/openapi-typescript/src/load.ts

+27-22
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface SchemaMap {
1111
[id: string]: Subschema;
1212
}
1313

14-
const EXT_RE = /\.(yaml|yml|json)$/i;
14+
const EXT_RE = /\.(yaml|yml|json)#?\/?/i;
1515
export const VIRTUAL_JSON_URL = `file:///_json`; // fake URL reserved for dynamic JSON
1616

1717
function parseYAML(schema: string) {
@@ -69,7 +69,7 @@ function parseHttpHeaders(httpHeaders: Record<string, unknown>): Record<string,
6969
const stringVal = JSON.stringify(v);
7070
finalHeaders[k] = stringVal;
7171
} catch (err) {
72-
error(`Cannot parse key: ${k} into JSON format. Continuing with the next HTTP header that is specified`);
72+
error(`Can’t parse key: ${k} into JSON format. Continuing with the next HTTP header that is specified`);
7373
}
7474
}
7575
}
@@ -99,9 +99,13 @@ export default async function load(schema: URL | Subschema | Readable, options:
9999
const hint = options.hint ?? "OpenAPI3";
100100

101101
// normalize ID
102-
if (schema.href !== options.rootURL.href) schemaID = relativePath(options.rootURL, schema);
102+
if (schema.href !== options.rootURL.href) {
103+
schemaID = relativePath(options.rootURL, schema);
104+
}
103105

104-
if (options.urlCache.has(schemaID)) return options.schemas; // exit early if already indexed
106+
if (options.urlCache.has(schemaID)) {
107+
return options.schemas; // exit early if already indexed
108+
}
105109
options.urlCache.add(schemaID);
106110

107111
const ext = path.extname(schema.pathname).toLowerCase();
@@ -138,16 +142,17 @@ export default async function load(schema: URL | Subschema | Readable, options:
138142
// local file
139143
else {
140144
const contents = fs.readFileSync(schema, "utf8");
141-
if (ext === ".yaml" || ext === ".yml")
145+
if (ext === ".yaml" || ext === ".yml") {
142146
options.schemas[schemaID] = {
143147
hint,
144148
schema: parseYAML(contents) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
145149
};
146-
else if (ext === ".json")
150+
} else if (ext === ".json") {
147151
options.schemas[schemaID] = {
148152
hint,
149153
schema: parseJSON(contents),
150154
};
155+
}
151156
}
152157
}
153158
// 1b. Readable stream
@@ -225,30 +230,23 @@ export default async function load(schema: URL | Subschema | Readable, options:
225230
hintPath.push(...ref.path);
226231
const hint = isRemoteFullSchema ? "OpenAPI3" : getHint({ path: hintPath, external: !!ref.filename, startFrom: options.hint });
227232

228-
// if root schema is remote and this is a relative reference, treat as remote
229-
if (schema instanceof URL) {
230-
const nextURL = new URL(ref.filename, schema);
231-
const nextID = relativePath(schema, nextURL);
232-
if (options.urlCache.has(nextID)) return;
233-
refPromises.push(load(nextURL, { ...options, hint }));
234-
node.$ref = node.$ref.replace(ref.filename, nextID);
235-
return;
236-
}
237-
// otherwise, if $ref is remote use that
238233
if (isRemoteURL(ref.filename) || isFilepath(ref.filename)) {
239234
const nextURL = new URL(ref.filename.startsWith("//") ? `https://${ref.filename}` : ref.filename);
240-
if (options.urlCache.has(nextURL.href)) return;
241235
refPromises.push(load(nextURL, { ...options, hint }));
242236
node.$ref = node.$ref.replace(ref.filename, nextURL.href);
243237
return;
244238
}
245-
// if this is dynamic JSON, we have no idea how to resolve external URLs, so throw here
239+
240+
// if this is dynamic JSON (with no cwd), we have no idea how to resolve external URLs, so throw here
246241
if (options.rootURL.href === VIRTUAL_JSON_URL) {
247-
error(`Can’t resolve "${ref.filename}" from dynamic JSON. Load this schema from a URL instead.`);
242+
error(`Can’t resolve "${ref.filename}" from dynamic JSON. Either load this schema from a filepath/URL, or set the \`cwd\` option: \`openapiTS(schema, { cwd: '/path/to/cwd' })\`.`);
248243
process.exit(1);
249244
}
250-
error(`Can’t resolve "${ref.filename}"`);
251-
process.exit(1);
245+
246+
const nextURL = new URL(ref.filename, schema instanceof URL ? schema : options.rootURL);
247+
const nextID = relativePath(schema instanceof URL ? schema : options.rootURL, nextURL);
248+
refPromises.push(load(nextURL, { ...options, hint }));
249+
node.$ref = node.$ref.replace(ref.filename, nextID);
252250
});
253251
await Promise.all(refPromises);
254252

@@ -322,7 +320,12 @@ export interface GetHintOptions {
322320
startFrom?: Subschema["hint"];
323321
}
324322

325-
/** given a path array (an array of indices), what type of object is this? */
323+
/**
324+
* Hinting
325+
* A remote `$ref` may point to anything—A full OpenAPI schema, partial OpenAPI schema, Schema Object, Parameter Object, etc.
326+
* The only way to parse its contents correctly is to trace the path from the root schema and infer the type it should be.
327+
* “Hinting” is the process of tracing its lineage back to the root schema to invoke the correct transformations on it.
328+
*/
326329
export function getHint({ path, external, startFrom }: GetHintOptions): Subschema["hint"] | undefined {
327330
if (startFrom && startFrom !== "OpenAPI3") {
328331
switch (startFrom) {
@@ -332,6 +335,8 @@ export function getHint({ path, external, startFrom }: GetHintOptions): Subschem
332335
return getHintFromRequestBodyObject(path, external);
333336
case "ResponseObject":
334337
return getHintFromResponseObject(path, external);
338+
case "SchemaMap":
339+
return "SchemaObject";
335340
default:
336341
return startFrom;
337342
}

packages/openapi-typescript/src/types.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { URL } from "node:url";
1+
import type { PathLike } from "node:fs";
22
import type { RequestInfo, RequestInit, Response } from "undici";
33
import type { TransformSchemaObjectOptions } from "./transform/schema-object.js";
44

@@ -614,7 +614,7 @@ export interface OpenAPITSOptions {
614614
/** Allow schema objects with no specified properties to have additional properties if not expressly forbidden? (default: false) */
615615
emptyObjectsUnknown?: boolean;
616616
/** Specify current working directory (cwd) to resolve remote schemas on disk (not needed for remote URL schemas) */
617-
cwd?: URL;
617+
cwd?: PathLike;
618618
/** Should schema objects with a default value not be considered optional? */
619619
defaultNonNullable?: boolean;
620620
/** Manually transform certain Schema Objects with a custom TypeScript type */
@@ -685,6 +685,7 @@ export type Subschema =
685685
export interface GlobalContext {
686686
additionalProperties: boolean;
687687
alphabetize: boolean;
688+
cwd?: PathLike;
688689
emptyObjectsUnknown: boolean;
689690
defaultNonNullable: boolean;
690691
discriminators: { [$ref: string]: DiscriminatorObject };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/ref-path:
2+
get:
3+
responses:
4+
200:
5+
description: OK
6+
content:
7+
application/json:
8+
schema:
9+
$ref: "./nested-ref-2/_nested-ref-2.yaml"
10+
StringParam:
11+
type: parameter
12+
required: true
13+
in: path
14+
schema:
15+
type: string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
type: object
2+
properties:
3+
string:
4+
type: string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
$ref: '../_nested-ref-2.yaml'

packages/openapi-typescript/test/fixtures/remote-ref-test.yaml

+9-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ paths:
1212
application/json:
1313
schema:
1414
$ref: "#/components/schemas/RemoteType"
15+
/ref-path:
16+
$ref: "./nested-ref/_nested-ref-partial.yaml#~1ref-path"
1517
components:
18+
parameters:
19+
NestedParam:
20+
$ref: "./nested-ref/_nested-ref-partial.yaml#StringParam"
1621
schemas:
22+
NestedType:
23+
$ref: "nested-ref/nested-ref-2/nested-ref-3/_nested-ref-3.yaml"
1724
RemoteType:
18-
$ref: "./remote-ref-test-2.yaml#/components/schemas/SchemaType"
25+
$ref: "_remote-ref-full.yaml#/components/schemas/SchemaType"
1926
RemotePartialType:
20-
$ref: '_schema-test-partial.yaml#/PartialType'
27+
$ref: "_remote-ref-partial.yaml#/PartialType"

0 commit comments

Comments
 (0)