UNPKG

openapi-typescript

Version:

Generate TypeScript types from Swagger OpenAPI specs

244 lines (213 loc) 8.9 kB
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; }