UNPKG

openapi-typescript

Version:

Convert OpenAPI 3.0 & 3.1 schemas to TypeScript

326 lines (274 loc) 10.6 kB
import c from "ansi-colors"; import { isAbsolute } from "node:path"; import supportsColor from "supports-color"; import { fetch as unidiciFetch } from "undici"; import type { Fetch } from "./types.js"; // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) c.enabled = false; export { c }; interface CommentObject { const?: unknown; // jsdoc without value default?: unknown; // jsdoc with value deprecated?: boolean; // jsdoc without value description?: string; // jsdoc with value enum?: unknown[]; // jsdoc without value example?: string; // jsdoc with value format?: string; // not jsdoc nullable?: boolean; // Node information summary?: string; // not jsdoc title?: string; // not jsdoc type?: string | string[]; // Type of node } const COMMENT_RE = /\*\//g; export const LB_RE = /\r?\n/g; export const DOUBLE_QUOTE_RE = /(?<!\\)"/g; const ESC_0_RE = /~0/g; const ESC_1_RE = /~1/g; const TILDE_RE = /~/g; const FS_RE = /\//g; export const TS_INDEX_RE = /\[("(\\"|[^"])+"|'(\\'|[^'])+')]/g; // splits apart TS indexes (and allows for escaped quotes) const TS_UNION_INTERSECTION_RE = /[&|]/; const TS_READONLY_RE = /^readonly\s+/; const JS_OBJ_KEY = /^(\d+|[A-Za-z_$][A-Za-z0-9_$]*)$/; /** Walk through any JSON-serializable object */ export function walk(obj: unknown, cb: (value: Record<string, unknown>, path: (string | number)[]) => void, path: (string | number)[] = []): void { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) walk(obj[i], cb, path.concat(i)); return; } cb(obj as Record<string, unknown>, path); for (const k of Object.keys(obj)) walk((obj as Record<string, unknown>)[k], cb, path.concat(k)); } /** * Preparing comments from fields * @see {comment} for output examples * @returns void if not comments or jsdoc format comment string */ export function getSchemaObjectComment(v: CommentObject, indentLv?: number): string | undefined { if (!v || typeof v !== "object") return; const output: string[] = []; // * Not JSDOC tags: [title, format] if (v.title) output.push(v.title); if (v.summary) output.push(v.summary); if (v.format) output.push(`Format: ${v.format}`); // * JSDOC tags without value // 'Deprecated' without value if (v.deprecated) output.push("@deprecated"); // * JSDOC tags with value const supportedJsDocTags: (keyof CommentObject)[] = ["description", "default", "example"]; for (const field of supportedJsDocTags) { const allowEmptyString = field === "default" || field === "example"; if (v[field] === undefined) { continue; } if (v[field] === "" && !allowEmptyString) { continue; } const serialized = typeof v[field] === "object" ? JSON.stringify(v[field], null, 2) : v[field]; output.push(`@${field} ${serialized}`); } // * JSDOC 'Constant' without value if ("const" in v) output.push("@constant"); // * JSDOC 'Enum' with type if (v.enum) { let type = "unknown"; if (Array.isArray(v.type)) type = v.type.join("|"); else if (typeof v.type === "string") type = v.type; output.push(`@enum {${type}${v.nullable ? `|null` : ""}}`); } return output.length ? comment(output.join("\n"), indentLv) : undefined; } /** wrap any string in a JSDoc-style comment wrapper */ export function comment(text: string, indentLv?: number): string { const commentText = text.trim().replace(COMMENT_RE, "*\\/"); // if single-line comment if (!commentText.includes("\n")) return `/** ${commentText} */`; // if multi-line comment const star = indent(" *", indentLv ?? 0); const body = commentText.split(LB_RE).map((ln) => { ln = ln.trimEnd(); return ln.length > 0 ? `${star} ${ln}` : star; }); return ["/**", body.join("\n"), indent(" */", indentLv ?? 0)].join("\n"); } /** handle any valid $ref */ export function parseRef(ref: string): { filename: string; path: string[] } { if (typeof ref !== "string") return { filename: ".", path: [] }; // OpenAPI $ref if (ref.includes("#/")) { const [filename, pathStr] = ref.split("#"); const parts = pathStr.split("/"); const path: string[] = []; for (const part of parts) { if (!part || part === "properties") continue; // remove empty parts and "properties" (gets flattened by TS) path.push(decodeRef(part)); } return { filename: filename || ".", path }; } // js-yaml $ref else if (ref.includes('["')) { const parts = ref.split('["'); const path: string[] = []; for (const part of parts) { const sanitized = part.replace('"]', "").trim(); if (!sanitized || sanitized === "properties") continue; path.push(sanitized); } return { filename: ".", path }; } // remote $ref return { filename: ref, path: [] }; } /** Parse TS index */ export function parseTSIndex(type: string): string[] { const parts: string[] = []; const bracketI = type.indexOf("["); if (bracketI === -1) return [type]; parts.push(type.substring(0, bracketI)); const matches = type.match(TS_INDEX_RE); if (matches) { for (const m of matches) parts.push(m.substring('["'.length, m.length - '"]'.length)); } return parts; } /** Make TS index */ export function makeTSIndex(parts: (string | number)[]): string { return `${parts[0]}[${parts.slice(1).map(escStr).join("][")}]`; } /** decode $ref (https://swagger.io/docs/specification/using-ref/#escape) */ export function decodeRef(ref: string): string { return ref.replace(ESC_0_RE, "~").replace(ESC_1_RE, "/").replace(DOUBLE_QUOTE_RE, '\\"'); } /** encode $ref (https://swagger.io/docs/specification/using-ref/#escape) */ export function encodeRef(ref: string): string { return ref.replace(TILDE_RE, "~0").replace(FS_RE, "~1"); } /** add parenthesis around union, intersection (| and &) and readonly types */ function parenthesise(type: string) { return TS_UNION_INTERSECTION_RE.test(type) || TS_READONLY_RE.test(type) ? `(${type})` : type; } /** T[] */ export function tsArrayOf(type: string): string { return `${parenthesise(type)}[]`; } /** X & Y & Z; */ export function tsIntersectionOf(...types: string[]): string { types = types.filter((t) => t !== "unknown"); if (types.length === 0) return "unknown"; if (types.length === 1) return String(types[0]); // don’t add parentheses around one thing return types.map((t) => parenthesise(t)).join(" & "); } /** NonNullable<T> */ export function tsNonNullable(type: string): string { return `NonNullable<${type}>`; } /** * OneOf<T> * TypeScript unions are not exclusive @see https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types * However, at a certain size, the helper type becomes too complex for inference to work. Hence the > check. */ export function tsOneOf(...types: string[]): string { if (types.length === 1) { return types[0]; } else if (types.length > 5) { return tsUnionOf(...types); } return `OneOf<[${types.join(", ")}]>`; } /** Pick<T> */ export function tsPick(root: string, keys: string[]): string { return `Pick<${root}, ${tsUnionOf(...keys.map(escStr))}>`; } /** Omit<T> */ export function tsOmit(root: string, keys: string[]): string { return `Omit<${root}, ${tsUnionOf(...keys.map(escStr))}>`; } /** WithRequired<T> */ export function tsWithRequired(root: string, keys: string[]): string { return `WithRequired<${root}, ${tsUnionOf(...keys.map(escStr))}>`; } /** make a given property key optional */ export function tsOptionalProperty(key: string): string { return `${key}?`; } /** make a given type readonly */ export function tsReadonly(type: string): string { return `readonly ${type}`; } /** [T, A, B, ...] */ export function tsTupleOf(...types: string[]): string { return `[${types.join(", ")}]`; } /** X | Y | Z */ export function tsUnionOf(...types: (string | number | boolean)[]): string { if (types.length === 0) return "never"; // de-duplicate the union const members = new Set<string>(); for (const t of types) { // unknown swallows everything else in the union if (t === "unknown") return "unknown"; members.add(String(t)); } // never gets swallowed by anything else, so only return never // if it is the only member if (members.has("never") && members.size === 1) return "never"; // (otherwise remove it) members.delete("never"); // return the only member without parentheses const memberArr = Array.from(members); if (members.size === 1) return memberArr[0]; // build the union string let out = ""; for (let i = 0; i < memberArr.length; i++) { const t = memberArr[i]; out += parenthesise(t); if (i !== memberArr.length - 1) out += " | "; } return out; } /** escape string value */ export function escStr(input: unknown): string { if (typeof input !== "string") return JSON.stringify(input); return `"${input.replace(LB_RE, "").replace(DOUBLE_QUOTE_RE, '\\"')}"`; } /** surround a JS object key with quotes, if needed */ export function escObjKey(input: string): string { return JS_OBJ_KEY.test(input) ? input : escStr(input); } /** Indent a string */ export function indent(input: string, level: number) { if (level > 0) { return " ".repeat(level).concat(input); } else { return input; } } /** call Object.entries() and optionally sort */ export function getEntries<T>(obj: ArrayLike<T> | Record<string, T>, alphabetize?: boolean, excludeDeprecated?: boolean) { let entries = Object.entries(obj); if (alphabetize) entries.sort(([a], [b]) => a.localeCompare(b, "en", { numeric: true })); if (excludeDeprecated) entries = entries.filter(([, v]) => !(v && typeof v === "object" && "deprecated" in v && v.deprecated)); return entries; } /** print error message */ export function error(msg: string) { console.error(c.red(` ✘ ${msg}`)); // eslint-disable-line no-console } /** is the given string a remote URL */ export function isRemoteURL(url: string): boolean { // believe it or not, this is faster than RegEx return url.startsWith("https://") || url.startsWith("//") || url.startsWith("http://"); } /** is the given string a filepath? */ export function isFilepath(url: string): boolean { return url.startsWith("file://") || isAbsolute(url); } export function getDefaultFetch(): Fetch { // @ts-expect-error globalThis doesn’t have a type const globalFetch: Fetch | undefined = globalThis.fetch; if (typeof globalFetch === "undefined") { return unidiciFetch; } return globalFetch; }