UNPKG

openapi-typescript

Version:

Convert OpenAPI 3.0 & 3.1 schemas to TypeScript

415 lines (379 loc) 16.4 kB
import type { ComponentsObject, DiscriminatorObject, Fetch, GlobalContext, OpenAPI3, OperationObject, ParameterObject, PathItemObject, ReferenceObject, RequestBodyObject, ResponseObject, SchemaObject, Subschema } from "./types.js"; import fs from "node:fs"; import path from "node:path"; import { Readable } from "node:stream"; import { URL } from "node:url"; import yaml from "js-yaml"; import type { Dispatcher } from "undici"; import { parseRef, error, makeTSIndex, walk, isRemoteURL, isFilepath } from "./utils.js"; interface SchemaMap { [id: string]: Subschema; } const EXT_RE = /\.(yaml|yml|json)#?\/?/i; export const VIRTUAL_JSON_URL = `file:///_json`; // fake URL reserved for dynamic JSON /** parse OpenAPI schema s YAML or JSON */ function parseSchema(source: string) { return source.trim().startsWith("{") ? JSON.parse(source) : yaml.load(source); } export function resolveSchema(filename: string): URL { // option 1: remote if (isRemoteURL(filename)) return new URL(filename.startsWith("//") ? `https:${filename}` : filename); // option 2: local const localPath = path.isAbsolute(filename) ? new URL(`file://${filename}`) : new URL(filename, `file://${process.cwd()}/`); if (!fs.existsSync(localPath)) { error(`Could not locate ${filename}`); process.exit(1); } else if (fs.statSync(localPath).isDirectory()) { error(`${localPath} is a directory not a file`); process.exit(1); } return localPath; } /** * Accepts income HTTP headers and appends them to * the fetch request for the schema. * * @param {HTTPHeaderMap} httpHeaders * @return {Record<string, string>} {Record<string, string>} Final HTTP headers outcome. */ function parseHttpHeaders(httpHeaders: Record<string, unknown>): Record<string, unknown> { const finalHeaders: Record<string, string> = {}; // Obtain the header key for (const [k, v] of Object.entries(httpHeaders)) { // If the value of the header is already a string, we can move on, otherwise we have to parse it if (typeof v === "string") { finalHeaders[k] = v; } else { try { const stringVal = JSON.stringify(v); finalHeaders[k] = stringVal; } catch (err) { error(`Can’t parse key: ${k} into JSON format. Continuing with the next HTTP header that is specified`); } } } return finalHeaders; } export interface LoadOptions extends GlobalContext { /** Subschemas may be any type; this helps transform correctly */ hint?: Subschema["hint"]; auth?: string; rootURL: URL; schemas: SchemaMap; urlCache: Set<string>; httpHeaders?: Record<string, unknown>; httpMethod?: string; fetch: Fetch; parameters: Record<string, ParameterObject>; } /** Load a schema from local path or remote URL */ export default async function load(schema: URL | Subschema | Readable, options: LoadOptions): Promise<{ [url: string]: Subschema }> { let schemaID = "."; // 1. load contents // 1a. URL if (schema instanceof URL) { const hint = options.hint ?? "OpenAPI3"; // normalize ID if (schema.href !== options.rootURL.href) { schemaID = relativePath(options.rootURL, schema); } if (options.urlCache.has(schemaID)) { return options.schemas; // exit early if already indexed } options.urlCache.add(schemaID); // remote if (schema.protocol.startsWith("http")) { const headers: Record<string, string> = { "User-Agent": "openapi-typescript" }; if (options.auth) headers.Authorization = options.auth; // Add custom parsed HTTP headers if (options.httpHeaders) { const parsedHeaders = parseHttpHeaders(options.httpHeaders); for (const [k, v] of Object.entries(parsedHeaders)) { headers[k] = v as string; } } const res = await options.fetch(schema, { method: (options.httpMethod as Dispatcher.HttpMethod) || "GET", headers, }); const contents = await res.text(); options.schemas[schemaID] = { hint, schema: parseSchema(contents) }; } // local file else { const contents = fs.readFileSync(schema, "utf8"); options.schemas[schemaID] = { hint, schema: parseSchema(contents) }; } } // 1b. Readable stream else if (schema instanceof Readable) { const readable = schema; const contents = await new Promise<string>((resolve) => { readable.resume(); readable.setEncoding("utf8"); let content = ""; readable.on("data", (chunk: string) => { content += chunk; }); readable.on("end", () => { resolve(content.trim()); }); }); options.schemas[schemaID] = { hint: "OpenAPI3", schema: parseSchema(contents) }; } // 1c. inline else if (typeof schema === "object") { options.schemas[schemaID] = { hint: "OpenAPI3", schema: JSON.parse(JSON.stringify(schema)), // create deep clone of inline schema (don’t mutate) }; } // 1d. failsafe else { error(`Invalid schema`); process.exit(1); } // 2. resolve $refs const currentSchema = options.schemas[schemaID].schema; // 2a. remove "components.examples" first if (options.schemas[schemaID].hint === "OpenAPI3") { if ("components" in currentSchema && currentSchema.components && "examples" in currentSchema.components) delete currentSchema.components.examples; } const refPromises: Promise<unknown>[] = []; walk(currentSchema, (rawNode, nodePath) => { // filter custom properties from allOf, anyOf, oneOf for (const k of ["allOf", "anyOf", "oneOf"]) { if (Array.isArray(rawNode[k])) { rawNode[k] = (rawNode as Record<string, SchemaObject[]>)[k].filter((o: SchemaObject | ReferenceObject) => { if (!o || typeof o !== "object" || Array.isArray(o)) throw new Error(`${nodePath}.${k}: Expected array of objects. Is your schema valid?`); if (!("$ref" in o) || typeof o.$ref !== "string") return true; const ref = parseRef(o.$ref); return !ref.path.some((i) => i.startsWith("x-")); // ignore all custom "x-*" properties }); } } if (!rawNode || typeof rawNode !== "object" || Array.isArray(rawNode)) throw new Error(`${nodePath}: Expected object, got ${Array.isArray(rawNode) ? "array" : typeof rawNode}. Is your schema valid?`); if (!("$ref" in rawNode) || typeof rawNode.$ref !== "string") return; const node = rawNode as unknown as ReferenceObject; const ref = parseRef(node.$ref); if (ref.filename === ".") { return; // local $ref; ignore } // $ref with custom "x-*" property if (ref.path.some((i) => i.startsWith("x-"))) { delete (node as unknown as Record<string, unknown>).$ref; return; } // hints help external partial schemas pick up where the root left off (for external complete/valid schemas, skip this) const isRemoteFullSchema = ref.path[0] === "paths" || ref.path[0] === "components"; // if the initial ref is "paths" or "components" this must be a full schema const hintPath: string[] = [...(nodePath as string[])]; if (ref.filename) hintPath.push(ref.filename); hintPath.push(...ref.path); const hint = isRemoteFullSchema ? "OpenAPI3" : getHint({ path: hintPath, external: !!ref.filename, startFrom: options.hint }); if (isRemoteURL(ref.filename) || isFilepath(ref.filename)) { const nextURL = new URL(ref.filename.startsWith("//") ? `https://${ref.filename}` : ref.filename); refPromises.push(load(nextURL, { ...options, hint })); node.$ref = node.$ref.replace(ref.filename, nextURL.href); return; } // if this is dynamic JSON (with no cwd), we have no idea how to resolve external URLs, so throw here if (options.rootURL.href === VIRTUAL_JSON_URL) { 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' })\`.`); process.exit(1); } const nextURL = new URL(ref.filename, schema instanceof URL ? schema : options.rootURL); const nextID = relativePath(schema instanceof URL ? schema : options.rootURL, nextURL); refPromises.push(load(nextURL, { ...options, hint })); node.$ref = node.$ref.replace(ref.filename, nextID); }); await Promise.all(refPromises); // 3. transform $refs once, at the root schema, after all have been scanned & downloaded (much easier to do here when we have the context) if (schemaID === ".") { for (const subschemaID of Object.keys(options.schemas)) { walk(options.schemas[subschemaID].schema, (rawNode) => { if (!("$ref" in rawNode) || typeof rawNode.$ref !== "string") return; const node = rawNode as unknown as ReferenceObject; const ref = parseRef(node.$ref); // local $ref: convert into TS path if (ref.filename === ".") { if (subschemaID === "." || ref.path[0] === "external") { node.$ref = makeTSIndex(ref.path); } else { node.$ref = makeTSIndex(["external", subschemaID, ...ref.path]); } } // external $ref else { const refURL = new URL(ref.filename, new URL(subschemaID, options.rootURL)); node.$ref = makeTSIndex(["external", relativePath(options.rootURL, refURL), ...ref.path]); } }); } } // 4. collect parameters (which must be hoisted to the top) for (const k of Object.keys(options.schemas)) { walk(options.schemas[k].schema, (rawNode, nodePath) => { // note: 'in' is a unique required property of parameters. and parameters can live in subschemas (i.e. "parameters" doesn’t have to be part of the traceable path) if (typeof rawNode === "object" && "in" in rawNode) { const key = k === "." ? makeTSIndex(nodePath) : makeTSIndex(["external", k, ...nodePath]); options.parameters[key] = rawNode as unknown as ParameterObject; } }); } // 5. scan for discriminators (after everything’s resolved in one file) for (const k of Object.keys(options.schemas)) { // 4a. lazy stringification check is faster than deep-scanning a giant object for discriminators // since most schemas don’t use them if (JSON.stringify(options.schemas[k].schema).includes('"discriminator"')) { walk(options.schemas[k].schema, (rawNode, nodePath) => { const node = rawNode as unknown as SchemaObject; if (!node.discriminator) return; const discriminator: DiscriminatorObject = { ...node.discriminator }; // handle child oneOf types (mapping isn’t explicit from children) const oneOf: string[] = []; if (Array.isArray(node.oneOf)) { for (const child of node.oneOf) { if (!child || typeof child !== "object" || !("$ref" in child)) continue; oneOf.push(child.$ref); } } if (oneOf.length) discriminator.oneOf = oneOf; options.discriminators[schemaID === "." ? makeTSIndex(nodePath) : makeTSIndex(["external", k, ...nodePath])] = discriminator; }); } } return options.schemas; } /** relative path from 2 URLs */ function relativePath(src: URL, dest: URL): string { const isSameOrigin = dest.protocol.startsWith("http") && src.protocol.startsWith("http") && dest.origin === src.origin; const isSameDisk = dest.protocol === "file:" && src.protocol === "file:"; if (isSameOrigin || isSameDisk) { return path.posix.relative(path.posix.dirname(src.pathname), dest.pathname); } return dest.href; } export interface GetHintOptions { path: string[]; external: boolean; startFrom?: Subschema["hint"]; } /** * Hinting * A remote `$ref` may point to anything—A full OpenAPI schema, partial OpenAPI schema, Schema Object, Parameter Object, etc. * The only way to parse its contents correctly is to trace the path from the root schema and infer the type it should be. * “Hinting” is the process of tracing its lineage back to the root schema to invoke the correct transformations on it. */ export function getHint({ path, external, startFrom }: GetHintOptions): Subschema["hint"] | undefined { if (startFrom && startFrom !== "OpenAPI3") { switch (startFrom) { case "OperationObject": return getHintFromOperationObject(path, external); case "RequestBodyObject": return getHintFromRequestBodyObject(path, external); case "ResponseObject": return getHintFromResponseObject(path, external); case "SchemaMap": return "SchemaObject"; default: return startFrom; } } switch (path[0] as keyof OpenAPI3) { case "paths": { // if entire path item object is $ref’d, treat as schema map if (EXT_RE.test(path[2])) { return "SchemaMap"; } return getHintFromPathItemObject(path.slice(2), external); // skip URL at [1] } case "components": return getHintFromComponentsObject(path.slice(1), external); } return undefined; } function getHintFromComponentsObject(path: (string | number)[], external: boolean): Subschema["hint"] | undefined { switch (path[0] as keyof ComponentsObject) { case "schemas": case "headers": return getHintFromSchemaObject(path.slice(2), external); case "parameters": return getHintFromParameterObject(path.slice(2), external); case "responses": return getHintFromResponseObject(path.slice(2), external); case "requestBodies": return getHintFromRequestBodyObject(path.slice(2), external); case "pathItems": return getHintFromPathItemObject(path.slice(2), external); } return "SchemaObject"; } function getHintFromMediaTypeObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0]) { case "schema": return getHintFromSchemaObject(path.slice(1), external); } return "MediaTypeObject"; } function getHintFromOperationObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0] as keyof OperationObject) { case "parameters": return "ParameterObject[]"; case "requestBody": return getHintFromRequestBodyObject(path.slice(1), external); case "responses": return getHintFromResponseObject(path.slice(2), external); // skip the response code at [1] } return "OperationObject"; } function getHintFromParameterObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0]) { case "content": return getHintFromMediaTypeObject(path.slice(2), external); // skip content type at [1] case "schema": return getHintFromSchemaObject(path.slice(1), external); } return "ParameterObject"; } function getHintFromPathItemObject(path: (string | number)[], external: boolean): Subschema["hint"] | undefined { switch (path[0] as keyof PathItemObject) { case "parameters": { if (typeof path[1] === "number") { return "ParameterObject[]"; } return getHintFromParameterObject(path.slice(1), external); } default: return getHintFromOperationObject(path.slice(1), external); } } function getHintFromRequestBodyObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0] as keyof RequestBodyObject) { case "content": return getHintFromMediaTypeObject(path.slice(2), external); // skip content type at [1] } return "RequestBodyObject"; } function getHintFromResponseObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0] as keyof ResponseObject) { case "headers": return getHintFromSchemaObject(path.slice(2), external); // skip name at [1] case "content": return getHintFromMediaTypeObject(path.slice(2), external); // skip content type at [1] } return "ResponseObject"; } function getHintFromSchemaObject(path: (string | number)[], external: boolean): Subschema["hint"] { switch (path[0]) { case "allOf": case "anyOf": case "oneOf": return "SchemaMap"; } // if this is external, and the path is [filename, key], then the external schema is probably a SchemaMap if (path.length >= 2 && external) { return "SchemaMap"; } // otherwise, path length of 1 means partial schema is likely a SchemaObject (or it’s unknown, in which case assume SchemaObject) return "SchemaObject"; }