UNPKG

openapi-typescript

Version:

Convert OpenAPI 3.0 & 3.1 schemas to TypeScript

254 lines (234 loc) 11.5 kB
import type { GlobalContext, OpenAPI3, OpenAPITSOptions, SchemaObject, Subschema } from "./types.js"; import type { Readable } from "node:stream"; import { URL } from "node:url"; import load, { resolveSchema, VIRTUAL_JSON_URL } from "./load.js"; import { transformSchema } from "./transform/index.js"; import transformMediaTypeObject from "./transform/media-type-object.js"; import transformOperationObject from "./transform/operation-object.js"; import transformParameterObject from "./transform/parameter-object.js"; import transformParameterObjectArray from "./transform/parameter-object-array.js"; import transformRequestBodyObject from "./transform/request-body-object.js"; import transformResponseObject from "./transform/response-object.js"; import transformSchemaObject from "./transform/schema-object.js"; import transformSchemaObjectMap from "./transform/schema-object-map.js"; import { error, escObjKey, getDefaultFetch, getEntries, getSchemaObjectComment, indent } from "./utils.js"; export * from "./types.js"; // expose all types to consumers const EMPTY_OBJECT_RE = /^\s*\{?\s*\}?\s*$/; export const COMMENT_HEADER = `/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ `; /** * This function is the entry to the program and allows the user to pass in a remote schema and/or local schema. * The URL or schema and headers can be passed in either programmatically and/or via the CLI. * Remote schemas are fetched from a server that supplies JSON or YAML format via an HTTP GET request. File based schemas * are loaded in via file path, most commonly prefixed with the file:// format. Alternatively, the user can pass in * OpenAPI2 or OpenAPI3 schema objects that can be parsed directly by the function without reading the file system. * * Function overloading is utilized for generating stronger types for our different schema types and option types. * * @param {string} schema Root Swagger Schema HTTP URL, File URL, and/or JSON or YAML schema * @param {SwaggerToTSOptions<typeof schema>} [options] Options to specify to the parsing system * @return {Promise<string>} {Promise<string>} Parsed file schema */ async function openapiTS(schema: string | URL | OpenAPI3 | Readable, options: OpenAPITSOptions = {} as Partial<OpenAPITSOptions>): Promise<string> { const ctx: GlobalContext = { additionalProperties: options.additionalProperties ?? false, alphabetize: options.alphabetize ?? false, cwd: options.cwd ?? new URL(`file://${process.cwd()}/`), defaultNonNullable: options.defaultNonNullable ?? false, discriminators: {}, transform: typeof options.transform === "function" ? options.transform : undefined, postTransform: typeof options.postTransform === "function" ? options.postTransform : undefined, immutableTypes: options.immutableTypes ?? false, emptyObjectsUnknown: options.emptyObjectsUnknown ?? false, indentLv: 0, operations: {}, pathParamsAsTypes: options.pathParamsAsTypes ?? false, parameters: {}, silent: options.silent ?? false, supportArrayLength: options.supportArrayLength ?? false, excludeDeprecated: options.excludeDeprecated ?? false, }; // 1. load schema (and subschemas) const allSchemas: { [id: string]: Subschema } = {}; const schemaURL: URL = typeof schema === "string" ? resolveSchema(schema) : (schema as URL); let rootURL: URL = schemaURL; // 1a. if passed as in-memory JSON, handle `cwd` option const isInlineSchema = typeof schema !== "string" && schema instanceof URL === false; // eslint-disable-line @typescript-eslint/no-unnecessary-boolean-literal-compare if (isInlineSchema) { if (ctx.cwd) { if (ctx.cwd instanceof URL) { rootURL = ctx.cwd; } else if (typeof ctx.cwd === "string") { rootURL = new URL(ctx.cwd, `file://${process.cwd()}/`); } rootURL = new URL("root.yaml", rootURL); // give the root schema an arbitrary filename ("root.yaml") } else { rootURL = new URL(VIRTUAL_JSON_URL); // otherwise, set virtual filename (which prevents resolutions) } } await load(schemaURL, { ...ctx, auth: options.auth, schemas: allSchemas, rootURL, urlCache: new Set(), httpHeaders: options.httpHeaders, httpMethod: options.httpMethod, fetch: options.fetch ?? getDefaultFetch(), }); // 1. basic validation for (const k of Object.keys(allSchemas)) { const subschema = allSchemas[k]; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (typeof (subschema.schema as any).swagger === "string") { error("Swagger 2.0 and older no longer supported. Please use v5."); process.exit(1); } if (subschema.hint === "OpenAPI3" && typeof subschema.schema.openapi === "string") { if (parseInt(subschema.schema.openapi) !== 3) { error(`Unsupported OpenAPI version "${subschema.schema.openapi}". Only 3.x is supported.`); process.exit(1); } } } // 2. generate raw output const output: string[] = []; // 2a. Start file, inject custom code (if any) if ("commentHeader" in options) { if (options.commentHeader) output.push(options.commentHeader); } else { output.push(COMMENT_HEADER); } // 2b. options.inject if (options.inject) output.push(options.inject); // 2c. root schema const rootTypes = transformSchema(allSchemas["."].schema as OpenAPI3, ctx); for (const k of Object.keys(rootTypes)) { if (rootTypes[k] && !EMPTY_OBJECT_RE.test(rootTypes[k])) { output.push(options.exportType ? `export type ${k} = ${rootTypes[k]};` : `export interface ${k} ${rootTypes[k]}`, ""); } else { output.push(`export type ${k} = Record<string, never>;`, ""); } delete rootTypes[k]; delete allSchemas["."]; // garbage collect, but also remove from next step (external) } // 2d. external schemas (subschemas) const externalKeys = Object.keys(allSchemas); // root schema (".") should already be removed if (externalKeys.length) { let indentLv = 0; output.push(options.exportType ? "export type external = {" : "export interface external {"); externalKeys.sort((a, b) => a.localeCompare(b, "en", { numeric: true })); // sort external keys because they may have resolved in a different order each time indentLv++; for (const subschemaID of externalKeys) { const subschema = allSchemas[subschemaID]; const key = escObjKey(subschemaID); const path = `${subschemaID}#`; let subschemaOutput = ""; let comment: string | undefined; switch (subschema.hint) { case "OpenAPI3": { const subschemaTypes = transformSchema(subschema.schema, { ...ctx, indentLv: indentLv + 1 }); if (!Object.keys(subschemaTypes).length) break; output.push(indent(`${key}: {`, indentLv)); indentLv++; for (const [k, v] of getEntries(subschemaTypes, options.alphabetize, options.excludeDeprecated)) { if (EMPTY_OBJECT_RE.test(v)) output.push(indent(`${escObjKey(k)}: Record<string, never>;`, indentLv)); else output.push(indent(`${escObjKey(k)}: ${v};`, indentLv)); } indentLv--; output.push(indent("};", indentLv)); break; } case "MediaTypeObject": { subschemaOutput = transformMediaTypeObject(subschema.schema, { path, ctx: { ...ctx, indentLv } }); break; } case "OperationObject": { comment = getSchemaObjectComment(subschema.schema, indentLv); subschemaOutput = transformOperationObject(subschema.schema, { path, ctx: { ...ctx, indentLv } }); break; } case "ParameterObject": { subschemaOutput = transformParameterObject(subschema.schema, { path, ctx: { ...ctx, indentLv } }); break; } case "ParameterObject[]": { // hack: sometimes subschemas contain only a single SchemaObject or ParameterObject and get incorrectly hinted // currently unknown what the real fix is, but this is a bandaid if (typeof subschema.schema === "object" && ("schema" in subschema.schema || "type" in subschema.schema)) { subschemaOutput = transformSchemaObject(subschema.schema as SchemaObject, { path, ctx: { ...ctx, indentLv } }); } else { subschemaOutput += "{\n"; indentLv++; subschemaOutput += transformParameterObjectArray(subschema.schema, { path, ctx: { ...ctx, indentLv } }); subschemaOutput += "\n"; indentLv--; subschemaOutput += indent("};", indentLv); } break; } case "RequestBodyObject": { subschemaOutput = `${transformRequestBodyObject(subschema.schema, { path, ctx: { ...ctx, indentLv } })};`; break; } case "ResponseObject": { subschemaOutput = `${transformResponseObject(subschema.schema, { path, ctx: { ...ctx, indentLv } })};`; break; } case "SchemaMap": { subschemaOutput = `${transformSchemaObjectMap(subschema.schema, { path, ctx: { ...ctx, indentLv } })};`; break; } case "SchemaObject": { subschemaOutput = `${transformSchemaObject(subschema.schema, { path, ctx: { ...ctx, indentLv } })};`; break; } default: { error(`Could not resolve subschema ${subschemaID}. Unknown type "${subschema.hint}".`); process.exit(1); } } if (subschemaOutput && !EMPTY_OBJECT_RE.test(subschemaOutput)) { if (comment) output.push(indent(comment, indentLv)); output.push(indent(`${key}: ${subschemaOutput}`, indentLv)); } delete allSchemas[subschemaID]; } indentLv--; output.push(indent(`}${options.exportType ? ";" : ""}`, indentLv), ""); } else { output.push(`export type external = Record<string, never>;`, ""); } // 3. operations (only get fully built after all external schemas transformed) if (Object.keys(ctx.operations).length) { output.push(options.exportType ? "export type operations = {" : "export interface operations {", ""); for (const [key, { operationType, comment }] of Object.entries(ctx.operations)) { if (comment) output.push(indent(comment, 1)); output.push(indent(`${escObjKey(key)}: ${operationType};`, 1)); } output.push(`}${options.exportType ? ";" : ""}`, ""); } else { output.push(`export type operations = Record<string, never>;`, ""); } // 4a. OneOf type helper (@see https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-723571692) if (output.join("\n").includes("OneOf")) { output.splice( 1, 0, "/** OneOf type helpers */", "type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };", "type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;", "type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;", "", ); } // 4b. WithRequired type helper (@see https://github.com/drwpow/openapi-typescript/issues/657#issuecomment-1399274607) if (output.join("\n").includes("WithRequired")) { output.splice(1, 0, "/** WithRequired type helpers */", "type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };", ""); } return output.join("\n"); } export default openapiTS;