openapi-typescript
Version:
Generate TypeScript types from Swagger OpenAPI specs
244 lines (213 loc) • 8.9 kB
text/typescript
import fetch from "node-fetch";
import fs from "fs";
import path from "path";
import { URL } from "url";
import slash from "slash";
import mime from "mime";
import yaml from "js-yaml";
import { red } from "kleur";
import { GlobalContext, Headers } from "./types";
import { parseRef } from "./utils";
type PartialSchema = Record<string, any>; // not a very accurate type, but this is easier to deal with before we know we’re dealing with a valid spec
type SchemaMap = { [url: string]: PartialSchema };
export const VIRTUAL_JSON_URL = `file:///_json`; // fake URL reserved for dynamic JSON
function parseSchema(schema: any, type: "YAML" | "JSON") {
if (type === "YAML") {
try {
return yaml.load(schema);
} catch (err: any) {
throw new Error(`YAML: ${err.toString()}`);
}
} else {
try {
return JSON.parse(schema);
} catch (err: any) {
throw new Error(`JSON: ${err.toString()}`);
}
}
}
function isFile(url: URL): boolean {
return url.protocol === "file:";
}
export function resolveSchema(url: string): URL {
// option 1: remote
if (url.startsWith("http://") || url.startsWith("https://")) {
return new URL(url);
}
// option 2: local
const localPath = path.isAbsolute(url)
? new URL("", `file://${slash(url)}`)
: new URL(url, `file://${slash(process.cwd())}/`); // if absolute path is provided use that; otherwise search cwd\
if (!fs.existsSync(localPath)) {
throw new Error(`Could not locate ${url}`);
} else if (fs.statSync(localPath).isDirectory()) {
throw new Error(`${localPath} is a directory not a file`);
}
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, any>): Headers {
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) {
/* istanbul ignore next */
console.error(
red(`Cannot parse key: ${k} into JSON format. Continuing with the next HTTP header that is specified`)
);
}
}
}
return finalHeaders;
}
interface LoadOptions extends GlobalContext {
rootURL: URL;
schemas: SchemaMap;
urlCache?: Set<string>; // URL cache (prevent URLs from being loaded over and over)
httpHeaders?: Headers;
httpMethod?: string;
}
/** Load a schema from local path or remote URL */
export default async function load(
schema: URL | PartialSchema,
options: LoadOptions
): Promise<{ [url: string]: PartialSchema }> {
const urlCache = options.urlCache || new Set<string>();
const isJSON = schema instanceof URL === false; // if this is dynamically-passed-in JSON, we’ll have to change a few things
let schemaID = isJSON ? new URL(VIRTUAL_JSON_URL).href : (schema.href as string);
const schemas = options.schemas;
// scenario 1: load schema from dynamic JSON
if (isJSON) {
schemas[schemaID] = schema;
}
// scenario 2: fetch schema from URL (local or remote)
else {
if (urlCache.has(schemaID)) return options.schemas; // exit early if this has already been scanned
urlCache.add(schemaID); // add URL to cache
let contents = "";
let contentType = "";
const schemaURL = schema as URL; // helps TypeScript
if (isFile(schemaURL)) {
// load local
contents = await fs.promises.readFile(schemaURL, "utf8");
contentType = mime.getType(schemaID) || "";
} else {
// load remote
const headers: Headers = {
"User-Agent": "openapi-typescript",
};
if (options.auth) headers.Authorizaton = 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;
}
}
const res = await fetch(schemaID, { method: options.httpMethod || "GET", headers });
contentType = res.headers.get("Content-Type") || "";
contents = await res.text();
}
const isYAML = contentType === "application/openapi+yaml" || contentType === "text/yaml";
const isJSON =
contentType === "application/json" ||
contentType === "application/json5" ||
contentType === "application/openapi+json";
if (isYAML) {
schemas[schemaID] = parseSchema(contents, "YAML");
} else if (isJSON) {
schemas[schemaID] = parseSchema(contents, "JSON");
} else {
// if contentType is unknown, guess
try {
schemas[schemaID] = parseSchema(contents, "JSON");
} catch (err1) {
try {
schemas[schemaID] = parseSchema(contents, "YAML");
} catch (err2) {
throw new Error(`Unknown format${contentType ? `: "${contentType}"` : ""}. Only YAML or JSON supported.`); // give up: unknown type
}
}
}
}
// scan $refs, but don’t transform (load everything in parallel)
const refPromises: Promise<any>[] = [];
schemas[schemaID] = JSON.parse(JSON.stringify(schemas[schemaID]), (k, v) => {
if (k !== "$ref" || typeof v !== "string") return v;
const { url: refURL } = parseRef(v);
if (refURL) {
// load $refs (only if new) and merge subschemas with top-level schema
const isRemoteURL = refURL.startsWith("http://") || refURL.startsWith("https://");
// if this is dynamic JSON, we have no idea how to resolve relative URLs, so throw here
if (isJSON && !isRemoteURL) {
throw new Error(`Can’t load URL "${refURL}" from dynamic JSON. Load this schema from a URL instead.`);
}
const nextURL = isRemoteURL ? new URL(refURL) : new URL(slash(refURL), schema as URL);
refPromises.push(
load(nextURL, { ...options, urlCache }).then((subschemas) => {
for (const subschemaURL of Object.keys(subschemas)) {
schemas[subschemaURL] = subschemas[subschemaURL];
}
})
);
return v.replace(refURL, nextURL.href); // resolve relative URLs to absolute URLs so the schema can be flattened
}
return v;
});
await Promise.all(refPromises);
// 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 === options.rootURL.href) {
for (const subschemaURL of Object.keys(schemas)) {
// transform $refs in schema
schemas[subschemaURL] = JSON.parse(JSON.stringify(schemas[subschemaURL]), (k, v) => {
if (k !== "$ref" || typeof v !== "string") return v;
if (!v.includes("#")) return v; // already transformed; skip
const { url, parts } = parseRef(v);
// scenario 1: resolve all external URLs so long as they don’t point back to root schema
if (url && new URL(url).href !== options.rootURL.href) {
const relativeURL =
isFile(new URL(url)) && isFile(options.rootURL)
? path.posix.relative(path.posix.dirname(options.rootURL.href), url)
: url;
return `external["${relativeURL}"]["${parts.join('"]["')}"]`; // export external ref
}
// scenario 2: treat all $refs in external schemas as external
if (!url && subschemaURL !== options.rootURL.href) {
const relativeURL =
isFile(new URL(subschemaURL)) && isFile(options.rootURL)
? path.posix.relative(path.posix.dirname(options.rootURL.href), subschemaURL)
: subschemaURL;
return `external["${relativeURL}"]["${parts.join('"]["')}"]`; // export external ref
}
// scenario 3: transform all $refs pointing back to root schema
const [base, ...rest] = parts;
return `${base}["${rest.join('"]["')}"]`; // transform other $refs to the root schema (including external refs that point back to the root schema)
});
// use relative keys for external schemas (schemas generated on different machines should have the same namespace)
if (subschemaURL !== options.rootURL.href) {
const relativeURL =
isFile(new URL(subschemaURL)) && isFile(options.rootURL)
? path.posix.relative(path.posix.dirname(options.rootURL.href), subschemaURL)
: subschemaURL;
if (relativeURL !== subschemaURL) {
schemas[relativeURL] = schemas[subschemaURL];
delete schemas[subschemaURL];
}
}
}
}
return schemas;
}