UNPKG

openapi-typescript

Version:

Convert OpenAPI 3.0 & 3.1 schemas to TypeScript

460 lines (422 loc) 13.6 kB
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; import ts, { LiteralTypeNode, TypeLiteralNode } from "typescript"; export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/; export const JS_ENUM_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+(.)?/g; export const JS_PROPERTY_INDEX_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+/g; export const BOOLEAN = ts.factory.createKeywordTypeNode( ts.SyntaxKind.BooleanKeyword, ); export const FALSE = ts.factory.createLiteralTypeNode(ts.factory.createFalse()); export const NEVER = ts.factory.createKeywordTypeNode( ts.SyntaxKind.NeverKeyword, ); export const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); export const NUMBER = ts.factory.createKeywordTypeNode( ts.SyntaxKind.NumberKeyword, ); export const QUESTION_TOKEN = ts.factory.createToken( ts.SyntaxKind.QuestionToken, ); export const STRING = ts.factory.createKeywordTypeNode( ts.SyntaxKind.StringKeyword, ); export const TRUE = ts.factory.createLiteralTypeNode(ts.factory.createTrue()); export const UNDEFINED = ts.factory.createKeywordTypeNode( ts.SyntaxKind.UndefinedKeyword, ); export const UNKNOWN = ts.factory.createKeywordTypeNode( ts.SyntaxKind.UnknownKeyword, ); const LB_RE = /\r?\n/g; const COMMENT_RE = /\*\//g; export interface AnnotatedSchemaObject { 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 } /** * Preparing comments from fields * @see {comment} for output examples * @returns void if not comments or jsdoc format comment string */ export function addJSDocComment( schemaObject: AnnotatedSchemaObject, node: ts.PropertySignature, ): void { if ( !schemaObject || typeof schemaObject !== "object" || Array.isArray(schemaObject) ) { return; } const output: string[] = []; // Not JSDoc tags: [title, format] if (schemaObject.title) { output.push(schemaObject.title.replace(LB_RE, "\n * ")); } if (schemaObject.summary) { output.push(schemaObject.summary.replace(LB_RE, "\n * ")); } if (schemaObject.format) { output.push(`Format: ${schemaObject.format}`); } // JSDoc tags without value // 'Deprecated' without value if (schemaObject.deprecated) { output.push("@deprecated"); } // JSDoc tags with value const supportedJsDocTags = ["description", "default", "example"] as const; for (const field of supportedJsDocTags) { const allowEmptyString = field === "default" || field === "example"; if (schemaObject[field] === undefined) { continue; } if (schemaObject[field] === "" && !allowEmptyString) { continue; } const serialized = typeof schemaObject[field] === "object" ? JSON.stringify(schemaObject[field], null, 2) : schemaObject[field]; output.push(`@${field} ${String(serialized).replace(LB_RE, "\n * ")}`); } // JSDoc 'Constant' without value if ("const" in schemaObject) { output.push("@constant"); } // JSDoc 'Enum' with type if (schemaObject.enum) { let type = "unknown"; if (Array.isArray(schemaObject.type)) { type = schemaObject.type.join("|"); } else if (typeof schemaObject.type === "string") { type = schemaObject.type; } output.push(`@enum {${type}${schemaObject.nullable ? `|null` : ""}}`); } // attach comment if it has content if (output.length) { let text = output.length === 1 ? `* ${output.join("\n")} ` : `* * ${output.join("\n * ")}\n `; text = text.replace(COMMENT_RE, "*\\/"); // prevent inner comments from leaking ts.addSyntheticLeadingComment( /* node */ node, /* kind */ ts.SyntaxKind.MultiLineCommentTrivia, // note: MultiLine just refers to a "/* */" comment /* text */ text, /* hasTrailingNewLine */ true, ); } } /** Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) */ export function oapiRef(path: string): ts.TypeNode { const { pointer } = parseRef(path); if (pointer.length === 0) { throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`); } let t: ts.TypeReferenceNode | ts.IndexedAccessTypeNode = ts.factory.createTypeReferenceNode( ts.factory.createIdentifier(String(pointer[0])), ); if (pointer.length > 1) { for (let i = 1; i < pointer.length; i++) { t = ts.factory.createIndexedAccessTypeNode( t, ts.factory.createLiteralTypeNode( typeof pointer[i]! === "number" ? ts.factory.createNumericLiteral(pointer[i]!) : ts.factory.createStringLiteral(pointer[i]! as string), ), ); } } return t; } export interface AstToStringOptions { fileName?: string; sourceText?: string; formatOptions?: ts.PrinterOptions; } /** Convert TypeScript AST to string */ export function astToString( ast: ts.Node | ts.Node[] | ts.TypeElement | ts.TypeElement[], options?: AstToStringOptions, ): string { const sourceFile = ts.createSourceFile( options?.fileName ?? "openapi-ts.ts", options?.sourceText ?? "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS, ); // @ts-expect-error it’s OK to overwrite statements once sourceFile.statements = ts.factory.createNodeArray( Array.isArray(ast) ? ast : [ast], ); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: false, ...options?.formatOptions, }); return printer.printFile(sourceFile); } /** Convert an arbitrary string to TS (assuming it’s valid) */ export function stringToAST(source: string): unknown[] { return ts.createSourceFile( /* fileName */ "stringInput", /* sourceText */ source, /* languageVersion */ ts.ScriptTarget.ESNext, /* setParentNodes */ undefined, /* scriptKind */ undefined, ).statements as any; // eslint-disable-line @typescript-eslint/no-explicit-any } /** * Deduplicate simple primitive types from an array of nodes * Note: won’t deduplicate complex types like objects */ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { const encounteredTypes = new Set<number>(); const filteredTypes: ts.TypeNode[] = []; for (const t of types) { // only mark for deduplication if this is not a const ("text" means it is a const) if (!("text" in ((t as LiteralTypeNode).literal ?? t))) { const { kind } = (t as LiteralTypeNode).literal ?? t; if (encounteredTypes.has(kind)) { continue; } if (tsIsPrimitive(t)) { encounteredTypes.add(kind); } } filteredTypes.push(t); } return filteredTypes; } /** Create a TS enum (with sanitized name and members) */ export function tsEnum( name: string, members: (string | number)[], metadata?: { name?: string; description?: string }[], options?: { readonly?: boolean; export?: boolean }, ) { let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => { const last = c[c.length - 1]; return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase(); }); if (Number(name[0]) >= 0) { enumName = `Value${name}`; } enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`; return ts.factory.createEnumDeclaration( /* modifiers */ options ? tsModifiers({ readonly: options.readonly ?? false, export: options.export ?? false, }) : undefined, /* name */ enumName, /* members */ members.map((value, i) => tsEnumMember(value, metadata?.[i]), ), ); } /** Sanitize TS enum member expression */ export function tsEnumMember( value: string | number, metadata: { name?: string; description?: string } = {}, ) { let name = metadata.name ?? String(value); if (!JS_PROPERTY_INDEX_RE.test(name)) { if (Number(name[0]) >= 0) { name = `Value${name}`.replace(".", "_"); // don't forged decimals; } name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, "_"); } let member; if (typeof value === "number") { member = ts.factory.createEnumMember( name, ts.factory.createNumericLiteral(value), ); } else { member = ts.factory.createEnumMember( name, ts.factory.createStringLiteral(value), ); } if (metadata.description == undefined) { return member; } return ts.addSyntheticLeadingComment( member, ts.SyntaxKind.SingleLineCommentTrivia, " ".concat(metadata.description.trim()), true, ); } /** Create an intersection type */ export function tsIntersection(types: ts.TypeNode[]): ts.TypeNode { if (types.length === 0) { return NEVER; } if (types.length === 1) { return types[0]; } return ts.factory.createIntersectionTypeNode(tsDedupe(types)); } /** Is this a primitive type (string, number, boolean, null, undefined)? */ export function tsIsPrimitive(type: ts.TypeNode): boolean { if (!type) { return true; } return ( ts.SyntaxKind[type.kind] === "BooleanKeyword" || ts.SyntaxKind[type.kind] === "NeverKeyword" || ts.SyntaxKind[type.kind] === "NullKeyword" || ts.SyntaxKind[type.kind] === "NumberKeyword" || ts.SyntaxKind[type.kind] === "StringKeyword" || ts.SyntaxKind[type.kind] === "UndefinedKeyword" || ("literal" in type && tsIsPrimitive(type.literal as TypeLiteralNode)) ); } /** Create a literal type */ export function tsLiteral(value: unknown): ts.TypeNode { if (typeof value === "string") { return ts.factory.createLiteralTypeNode( ts.factory.createStringLiteral(value), ); } if (typeof value === "number") { return ts.factory.createLiteralTypeNode( ts.factory.createNumericLiteral(value), ); } if (typeof value === "boolean") { return value === true ? TRUE : FALSE; } if (value === null) { return NULL; } if (Array.isArray(value)) { if (value.length === 0) { return ts.factory.createArrayTypeNode(NEVER); } return ts.factory.createTupleTypeNode( value.map((v: unknown) => tsLiteral(v)), ); } if (typeof value === "object") { const keys: ts.TypeElement[] = []; for (const [k, v] of Object.entries(value)) { keys.push( ts.factory.createPropertySignature( /* modifiers */ undefined, /* name */ tsPropertyIndex(k), /* questionToken */ undefined, /* type */ tsLiteral(v), ), ); } return keys.length ? ts.factory.createTypeLiteralNode(keys) : tsRecord(STRING, NEVER); } return UNKNOWN; } /** Modifiers (readonly) */ export function tsModifiers(modifiers: { readonly?: boolean; export?: boolean; }): ts.Modifier[] { const typeMods: ts.Modifier[] = []; if (modifiers.export) { typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)); } if (modifiers.readonly) { typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)); } return typeMods; } /** Create a T | null union */ export function tsNullable(types: ts.TypeNode[]): ts.TypeNode { return ts.factory.createUnionTypeNode([...types, NULL]); } /** Create a TS Omit<X, Y> type */ export function tsOmit(type: ts.TypeNode, keys: string[]): ts.TypeNode { return ts.factory.createTypeReferenceNode( ts.factory.createIdentifier("Omit"), [type, ts.factory.createUnionTypeNode(keys.map((k) => tsLiteral(k)))], ); } /** Create a TS Record<X, Y> type */ export function tsRecord(key: ts.TypeNode, value: ts.TypeNode) { return ts.factory.createTypeReferenceNode( ts.factory.createIdentifier("Record"), [key, value], ); } /** Create a valid property index */ export function tsPropertyIndex(index: string | number) { if ( (typeof index === "number" && !(index < 0)) || (typeof index === "string" && String(Number(index)) === index && index[0] !== "-") ) { return ts.factory.createNumericLiteral(index); } return typeof index === "string" && JS_PROPERTY_INDEX_RE.test(index) ? ts.factory.createIdentifier(index) : ts.factory.createStringLiteral(String(index)); } /** Create a union type */ export function tsUnion(types: ts.TypeNode[]): ts.TypeNode { if (types.length === 0) { return NEVER; } if (types.length === 1) { return types[0]; } return ts.factory.createUnionTypeNode(tsDedupe(types)); } /** Create a WithRequired<X, Y> type */ export function tsWithRequired( type: ts.TypeNode, keys: string[], injectFooter: ts.Node[], // needed to inject type helper if used ): ts.TypeNode { if (keys.length === 0) { return type; } // inject helper, if needed if ( !injectFooter.some( (node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "WithRequired", ) ) { const helper = stringToAST( `type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };`, )[0] as any; // eslint-disable-line @typescript-eslint/no-explicit-any injectFooter.push(helper); } return ts.factory.createTypeReferenceNode( ts.factory.createIdentifier("WithRequired"), [type, tsUnion(keys.map((k) => tsLiteral(k)))], ); }