diff --git a/.changeset/string-templates-drop-typescript-dep.md b/.changeset/string-templates-drop-typescript-dep.md new file mode 100644 index 000000000..46f5d5585 --- /dev/null +++ b/.changeset/string-templates-drop-typescript-dep.md @@ -0,0 +1,50 @@ +--- +"openapi-typescript": major +--- + +feat: generate TypeScript with raw string templates and drop the TypeScript dependency + +`openapi-typescript` no longer uses the TypeScript compiler API (`ts.factory`, +`createPrinter`, `createSourceFile`) at runtime. Generation now builds the +`.d.ts` source with string templates, so the package + +- works with **TypeScript 7** (the native compiler), which ships no classic + compiler API — this fixes the `Cannot read properties of undefined (reading + 'createKeywordTypeNode')` crash, +- has **no runtime TypeScript dependency**: `typescript` is no longer a peer + dependency and is not resolved at all when generating, +- produces the same output as 7.x: every committed example snapshot, the test + suite, and a differential run over 27 option combinations are byte-for-byte + identical. + +Three deliberate output differences, all of them fixes for invalid or redundant +output (see the PR description for reproductions): + +- `tsUnion()` / `tsIntersection()` no longer emit a redundant single-member + union, so `(string)[][]` is now `string[][]`. Semantically identical. +- A multi-line `x-enum-descriptions` entry no longer leaks a bare token into the + enum body (it used to produce invalid TypeScript); line breaks become spaces. +- With `pathParamsAsTypes`, a URL containing a backtick or `${` no longer breaks + out of the generated template literal type. + +**Breaking changes** + +- `openapiTS()` now resolves to a `string` (the generated file body, without the + comment header) instead of an array of AST nodes. The body ends with a newline. +- The transform hooks exchange plain strings instead of AST nodes: + - `transform` returns `string | { schema: string; questionToken: boolean }` + - `postTransform` receives and returns `string` + - `transformProperty` receives and returns `{ name, optional, type, comment?, indent }`; + use the new `tsComment()` helper to attach JSDoc from that hook + - `GlobalContext.injectFooter` is now a `FooterDeclaration[]` (generated strings, + plus a deferred `OperationsDeclaration` for the `operations` interface) +- Callbacks that used to build AST nodes with a separately installed `typescript` + must return the equivalent type text instead, e.g. + `ts.factory.createTypeReferenceNode("Date")` becomes `"Date"`. `typescript` is + no longer required to use this package at all. +- The AST-oriented helpers `stringToAST()`, `tsModifiers()` and `QUESTION_TOKEN` + were removed and are replaced by string builders: `typeLiteral()`, + `tupleType()`, `propertySignature()`, `indexSignature()`, `typeAlias()`, + `interfaceDecl()`, `enumDecl()`, `INDENT` and `tsComment()`. +- The `inject` option is now emitted verbatim instead of being re-printed by the + TypeScript printer. diff --git a/docs/introduction.md b/docs/introduction.md index cff0f2ac2..9b8709944 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -33,7 +33,7 @@ _Note: OpenAPI 2.x is supported with versions `5.x` and previous_ This library requires the latest version of [Node.js](https://nodejs.org) installed (20.x or higher recommended). With that present, run the following in your project: ```bash -npm i -D openapi-typescript typescript +npm i -D openapi-typescript ``` And in your `tsconfig.json`, to load the types properly: diff --git a/docs/node.md b/docs/node.md index bd837887a..99bb3f3c3 100644 --- a/docs/node.md +++ b/docs/node.md @@ -10,7 +10,7 @@ The Node API may be useful if dealing with dynamically-created schemas, or you ## Setup ```bash -npm i --save-dev openapi-typescript typescript +npm i --save-dev openapi-typescript ``` ::: tip Recommended @@ -31,18 +31,21 @@ The Node.js API accepts either a `URL`, `string`, or JSON object as input: It also accepts `Readable` streams and `Buffer` types that are resolved and treated as strings (validation, bundling, and type generation can’t really happen without the whole document). -The Node API returns a `Promise` with a TypeScript AST. You can then traverse / manipulate / modify the AST as you see fit. +The Node API returns a `Promise` that resolves to the generated TypeScript source, ready to write to a file. -To convert the TypeScript AST into a string, you can use `astToString()` helper which is a thin wrapper around [TypeScript’s printer](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#re-printing-sections-of-a-typescript-file): +::: tip + +Because generation no longer uses the TypeScript compiler API, `openapi-typescript` works with any TypeScript version — including TypeScript 7 — and doesn’t require `typescript` to be installed at all. The `astToString()` helper is still exported for code written against older versions; it now simply normalizes the source into a file body ending in a newline. + +::: ::: code-group ```ts [src/my-project.ts] import fs from "node:fs"; -import openapiTS, { astToString } from "openapi-typescript"; +import openapiTS from "openapi-typescript"; -const ast = await openapiTS(new URL("./my-schema.yaml", import.meta.url)); -const contents = astToString(ast); +const contents = await openapiTS(new URL("./my-schema.yaml", import.meta.url)); // (optional) write to file fs.writeFileSync("./my-schema.ts", contents); @@ -74,7 +77,7 @@ const redocly = await createConfig( // option 2: load from redocly.yaml file const redocly = await loadConfig({ configPath: "redocly.yaml" }); -const ast = await openapiTS(mySchema, { redocly }); +const types = await openapiTS(mySchema, { redocly }); ``` ::: @@ -97,7 +100,7 @@ The Node API supports all the [CLI flags](/cli#flags) in `camelCase` format, plu Use the `transform()` and `postTransform()` options to override the default Schema Object transformer with your own. This is useful for providing nonstandard modifications for specific parts of your schema. - `transform()` runs **before** the conversion to TypeScript (you’re working with the original OpenAPI nodes) -- `postTransform()` runs **after** the conversion to TypeScript (you’re working with TypeScript AST) +- `postTransform()` runs **after** the conversion to TypeScript (you’re working with the generated type text) #### Example: `Date` types @@ -116,17 +119,14 @@ By default, openapiTS will generate `updated_at?: string;` because it’s not su ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const DATE = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Date")); // `Date` -const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` +const DATE = "Date"; // `Date` +const NULL = "null"; // `null` -const ast = await openapiTS(mySchema, { +const types = await openapiTS(mySchema, { transform(schemaObject, metadata) { if (schemaObject.format === "date-time") { - return schemaObject.nullable - ? ts.factory.createUnionTypeNode([DATE, NULL]) - : DATE; + return schemaObject.nullable ? `${DATE} | ${NULL}` : DATE; } }, }); @@ -168,17 +168,14 @@ Use the same pattern to transform the types: ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Blob")); // `Blob` -const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` +const BLOB = "Blob"; // `Blob` +const NULL = "null"; // `null` -const ast = await openapiTS(mySchema, { +const types = await openapiTS(mySchema, { transform(schemaObject, metadata) { if (schemaObject.format === "binary") { - return schemaObject.nullable - ? ts.factory.createUnionTypeNode([BLOB, NULL]) - : BLOB; + return schemaObject.nullable ? `${BLOB} | ${NULL}` : BLOB; } }, }); @@ -221,18 +218,15 @@ Here we return an object with a schema property, which is the same as the above ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Blob")); // `Blob` -const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); // `null` +const BLOB = "Blob"; // `Blob` +const NULL = "null"; // `null` -const ast = await openapiTS(mySchema, { +const types = await openapiTS(mySchema, { transform(schemaObject, metadata) { if (schemaObject.format === "binary") { return { - schema: schemaObject.nullable - ? ts.factory.createUnionTypeNode([BLOB, NULL]) - : BLOB, + schema: schemaObject.nullable ? `${BLOB} | ${NULL}` : BLOB, questionToken: true, }; } @@ -263,7 +257,7 @@ Use the `transformProperty()` option to modify individual property signatures wi - `transformProperty()` runs **after** type conversion but **before** JSDoc comments are added - It receives the property signature, the original schema object, and transformation options -- It should return a modified `PropertySignature` or `undefined` to leave the property unchanged +- It should return a modified `{ name, optional, type, comment?, indent }` object, or `undefined` to leave the property unchanged #### Example: JSDoc validation annotations @@ -297,10 +291,9 @@ components: ```ts [src/my-project.ts] import fs from "node:fs"; -import ts from "typescript"; -import openapiTS, { astToString } from "openapi-typescript"; +import openapiTS, { tsComment } from "openapi-typescript"; -const ast = await openapiTS(mySchema, { +const contents = await openapiTS(mySchema, { transformProperty(property, schemaObject, options) { const validationTags: string[] = []; @@ -326,33 +319,13 @@ const ast = await openapiTS(mySchema, { // If we have validation tags, add them as JSDoc comments if (validationTags.length > 0) { - // Create a new property signature - const newProperty = ts.factory.updatePropertySignature( - property, - property.modifiers, - property.name, - property.questionToken, - property.type, - ); - - // Add JSDoc comment - const jsDocText = `*\n * ${validationTags.join('\n * ')}\n `; - - ts.addSyntheticLeadingComment( - newProperty, - ts.SyntaxKind.MultiLineCommentTrivia, - jsDocText, - true, - ); - - return newProperty; + return { ...property, comment: tsComment(validationTags, property.indent) }; } - + return property; }, }); -const contents = astToString(ast); fs.writeFileSync("./my-schema.ts", contents); ``` @@ -388,6 +361,6 @@ export interface components { ::: The `transformProperty` function provides access to: -- `property`: The TypeScript PropertySignature AST node +- `property`: The generated property signature as a plain object — `{ name, optional, type, comment?, indent }`. Return the same shape to replace it, or `undefined` to keep it unchanged. Use `tsComment()` to attach JSDoc. - `schemaObject`: The original OpenAPI Schema Object for this property - `options`: Transformation context including path information and other utilities diff --git a/packages/openapi-typescript/package.json b/packages/openapi-typescript/package.json index 2b322a039..281eb0950 100644 --- a/packages/openapi-typescript/package.json +++ b/packages/openapi-typescript/package.json @@ -58,9 +58,6 @@ "prepublish": "pnpm run build", "version": "pnpm run build" }, - "peerDependencies": { - "typescript": "^5.x" - }, "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index d042b81e4..138478d9a 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -1,8 +1,8 @@ import { performance } from "node:perf_hooks"; import type { Readable } from "node:stream"; import { createConfig } from "@redocly/openapi-core"; -import type ts from "typescript"; import { validateAndBundle } from "./lib/redoc.js"; +import type { TSNode } from "./lib/ts.js"; import { debug, resolveRef, scanDiscriminators } from "./lib/utils.js"; import transformSchema from "./transform/index.js"; import type { GlobalContext, OpenAPI3, OpenAPITSOptions } from "./types.js"; @@ -34,7 +34,7 @@ export const COMMENT_HEADER = `/** `; /** - * Convert an OpenAPI schema to TypesScript AST + * Convert an OpenAPI schema to TypeScript type declarations * @param {string|URL|object|Readable} source OpenAPI schema source: * - YAML: string * - JSON: parsed object @@ -44,7 +44,7 @@ export const COMMENT_HEADER = `/** export default async function openapiTS( source: string | URL | OpenAPI3 | Buffer | Readable, options: OpenAPITSOptions = {} as Partial, -): Promise { +): Promise { if (!source) { throw new Error("Empty schema. Please specify a URL, file path, or Redocly Config"); } @@ -101,7 +101,7 @@ export default async function openapiTS( }; const transformT = performance.now(); - const result = transformSchema(schema, ctx); + const result = `${transformSchema(schema, ctx).join("\n")}\n`; debug("Completed AST transformation for entire document", "ts", performance.now() - transformT); return result; diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index d1f41eb88..37277b90e 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -1,6 +1,5 @@ import type { OasRef, Referenced } from "@redocly/openapi-core"; import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; -import ts, { type LiteralTypeNode, type TypeLiteralNode } from "typescript"; import type { ParameterObject } from "../types.js"; export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/; @@ -11,18 +10,38 @@ export const SPECIAL_CHARACTER_MAP: Record = { // Add more mappings as needed }; -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; +/** + * A generated TypeScript source fragment. + * + * Two flavours exist, and mixing them up shifts indentation: + * + * - **expression fragments** (`string`, `Foo | Bar`, an object/tuple literal) start + * with no leading whitespace and are meant to be appended after `name: ` or `= `. + * A multi-line expression still carries the absolute indentation of its own + * interior lines, and its closing `}` / `]` sits at the fragment’s `indent`. + * - **line fragments** (`propertySignature`, `typeAlias`, …) already include the + * leading indentation of their own line. + * + * Everything reproduces byte-for-byte what the TypeScript compiler printer used + * to emit, so this package no longer needs the TypeScript compiler API at runtime. + */ +export type TSNode = string; + +/** One indentation level. Matches the TypeScript printer’s 4-space default. */ +export const INDENT = " "; + +// Primitive type keywords & literals +export const BOOLEAN = "boolean"; +export const FALSE = "false"; +export const NEVER = "never"; +export const NULL = "null"; +export const NUMBER = "number"; +export const STRING = "string"; +export const TRUE = "true"; +export const UNDEFINED = "undefined"; +export const UNKNOWN = "unknown"; + +const COMMENT_LB_RE = /\r?\n/g; const COMMENT_RE = /\*\//g; export interface AnnotatedSchemaObject { @@ -40,23 +59,39 @@ export interface AnnotatedSchemaObject { type?: string | string[]; // Type of node } +/** + * Render a comment body (the text that follows `/*`, including its leading `*`) + * into an indented block comment, replaying how the TypeScript printer emitted + * synthetic leading comments. + * + * Note: the printer strips trailing whitespace from every emitted line, which + * matters for multi-line comments whose continuation lines end in padding. + */ +function renderComment(text: string, indent: string): string { + const body = `/*${text}*/`; + return `${indent}${body + .split("\n") + .map((line) => line.replace(/[ \t]+$/, "")) + .join(`\n${indent}`)}`; +} + /** * Preparing comments from fields * @see {comment} for output examples - * @returns void if not comments or jsdoc format comment string + * @returns empty string if no comment, else the JSDoc block (with trailing newline) */ -export function addJSDocComment(schemaObject: AnnotatedSchemaObject, node: ts.PropertySignature): void { +export function addJSDocComment(schemaObject: AnnotatedSchemaObject, indent = ""): string { if (!schemaObject || typeof schemaObject !== "object" || Array.isArray(schemaObject)) { - return; + return ""; } const output: string[] = []; // Not JSDoc tags: [title, format] if (schemaObject.title) { - output.push(schemaObject.title.trim().replace(LB_RE, "\n * ")); + output.push(schemaObject.title.trim().replace(COMMENT_LB_RE, "\n * ")); } if (schemaObject.summary) { - output.push(schemaObject.summary.trim().replace(LB_RE, "\n * ")); + output.push(schemaObject.summary.trim().replace(COMMENT_LB_RE, "\n * ")); } if (schemaObject.format) { output.push(`Format: ${schemaObject.format}`); @@ -80,13 +115,13 @@ export function addJSDocComment(schemaObject: AnnotatedSchemaObject, node: ts.Pr } const serialized = typeof schemaObject[field] === "object" ? JSON.stringify(schemaObject[field], null, 2) : schemaObject[field]; - output.push(`@${field} ${String(serialized).trim().replace(LB_RE, "\n * ")}`); + output.push(`@${field} ${String(serialized).trim().replace(COMMENT_LB_RE, "\n * ")}`); } if (Array.isArray(schemaObject.examples)) { for (const example of schemaObject.examples) { const serialized = typeof example === "object" ? JSON.stringify(example, null, 2) : example; - output.push(`@example ${String(serialized).trim().replace(LB_RE, "\n * ")}`); + output.push(`@example ${String(serialized).trim().replace(COMMENT_LB_RE, "\n * ")}`); } } @@ -107,290 +142,486 @@ export function addJSDocComment(schemaObject: AnnotatedSchemaObject, node: ts.Pr } // attach comment if it has content + if (!output.length) { + return ""; + } - if (output.length) { - // Check if any output item contains multi-line content (has internal line breaks) - const hasMultiLineContent = output.some((item) => item.includes("\n")); + // Check if any output item contains multi-line content (has internal line breaks) + const hasMultiLineContent = output.some((item) => item.includes("\n")); - let text = - output.length === 1 && !hasMultiLineContent ? `* ${output.join("\n")} ` : `*\n * ${output.join("\n * ")}\n `; - text = text.replace(COMMENT_RE, "*\\/"); // prevent inner comments from leaking + let text = + output.length === 1 && !hasMultiLineContent ? `* ${output.join("\n")} ` : `*\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, - ); - } + return `${renderComment(text, indent)}\n`; } -function isOasRef(obj: Referenced): obj is OasRef { - return Boolean((obj as OasRef).$ref); +/** + * Render comment lines as a multi-line JSDoc block, indented at `indent` and + * terminated by a newline. + * + * Public helper for `transformProperty`, which may need to annotate a property + * (e.g. with validation tags) the same way `addJSDocComment` does internally. + */ +export function tsComment(lines: string[], indent = ""): TSNode { + // an embedded line break would escape the ` * ` gutter, so flatten each line + const flat = lines.flatMap((line) => line.split(COMMENT_LB_RE)); + const text = `*\n * ${flat.join("\n * ")}\n `.replace(COMMENT_RE, "*\\/"); + return `${renderComment(text, indent)}\n`; } -type OapiRefResolved = Referenced; -function isParameterObject(obj: OapiRefResolved | undefined): obj is ParameterObject { - return Boolean(obj && !isOasRef(obj) && obj.in); -} +// --------------------------------------------------------------------------- +// Expression fragments +// --------------------------------------------------------------------------- -function addIndexedAccess(node: ts.TypeNode, ...segments: readonly string[]) { - return segments.reduce((acc, segment) => { - return ts.factory.createIndexedAccessTypeNode( - acc, - ts.factory.createLiteralTypeNode( - typeof segment === "number" - ? ts.factory.createNumericLiteral(segment) - : ts.factory.createStringLiteral(segment), - ), - ); - }, node); +/** A `{ ... }` object type; renders `{}` when empty. Members are line fragments. */ +export function typeLiteral(members: TSNode[], indent = ""): TSNode { + return members.length ? `{\n${members.join("\n")}\n${indent}}` : "{}"; } /** - * Wrap a type with Extract to narrow a union type - * before accessing a property that only exists on some variants. + * A `[ ... ]` tuple type. Always multi-line, like the TypeScript printer + * (even when empty or single-element). Elements are expression fragments. */ -function wrapWithExtract(type: ts.TypeNode, propertyName: string): ts.TypeNode { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Extract"), [ - type, - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ ts.factory.createIdentifier(propertyName), - /* questionToken */ undefined, - /* type */ ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ), - ]), - ]); +export function tupleType(elements: TSNode[], indent = ""): TSNode { + if (!elements.length) { + return `[\n${indent}]`; + } + const elementIndent = `${indent}${INDENT}`; + return `[\n${elements.map((e) => `${elementIndent}${e}`).join(",\n")}\n${indent}]`; } -export interface OapiRefOptions { - /** Whether to wrap with FlattenedDeepRequired<> (default: false) */ - deep?: boolean; - /** Array of property names to wrap with Extract<> when accessing */ - extractProperties?: string[]; +/** + * Deduplicate simple primitive types from an array of nodes + * Note: won’t deduplicate complex types like objects + */ +export function tsDedupe(types: TSNode[]): TSNode[] { + const encounteredTypes = new Set(); + const filteredTypes: TSNode[] = []; + for (const t of types) { + // only deduplicate primitive keyword types (literals are left untouched) + if (tsIsPrimitive(t)) { + if (encounteredTypes.has(t)) { + continue; + } + encounteredTypes.add(t); + } + filteredTypes.push(t); + } + return filteredTypes; } /** - * Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) - * `path` is a JSON Pointer to a location within an OpenAPI document. - * Transform it into a TypeScript type reference into the generated types. + * Is this a primitive keyword type? * - * In most cases the structures of the openapi-typescript generated types and the - * JSON Pointer paths into the OpenAPI document are the same. However, in some cases - * special transformations are necessary to account for the ways they differ. - * * Object schemas - * $refs into the `properties` of object schemas are valid, but openapi-typescript - * flattens these objects, so we omit so the index into the schema skips ["properties"] - * * Parameters - * $refs into the `parameters` of paths are valid, but openapi-ts represents - * them according to their type; path, query, header, etc… so in these cases we - * must check the parameter definition to determine the how to index into - * the openapi-typescript type. - * * Union variant properties (oneOf/anyOf) - * When accessing properties that may only exist on some variants of a union type, - * we use Extract<> to narrow the type before each property access. - **/ -export function oapiRef(path: string, resolved?: OapiRefResolved, options: OapiRefOptions = {}): ts.TypeNode { - const { pointer } = parseRef(path); - if (pointer.length === 0) { - throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`); + * Note: this intentionally matches the legacy AST check, which only recognised + * keyword type nodes (`boolean`, `never`, `null`, `number`, `string`, + * `undefined`) — not `true`/`false` and not literal types. + */ +export function tsIsPrimitive(type: TSNode): boolean { + if (!type) { + return true; } + return ( + type === BOOLEAN || type === NEVER || type === NULL || type === NUMBER || type === STRING || type === UNDEFINED + ); +} - const parametersObject = isParameterObject(resolved); - const extractSet = new Set(options.extractProperties ?? []); +function renderNumberLiteral(value: number): string { + return value < 0 ? `-${Math.abs(value)}` : String(value); +} - // Initial segments are handled in a fixed , then remaining segments are treated - // according to heuristics based on the initial segments - const initialSegment = pointer[0]; - const leadingSegments = pointer.slice(1, 3); - const restSegments = pointer.slice(3); +/** `\uXXXX` escape for a UTF-16 code unit (TypeScript’s `encodeUtf16EscapeSequence`). */ +function encodeUtf16EscapeSequence(charCode: number): string { + return `\\u${charCode.toString(16).toUpperCase().padStart(4, "0")}`; +} - const leadingType = addIndexedAccess( - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier( - options.deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment), - ), - ), - ...leadingSegments, - ); +// TypeScript’s `escapedCharsMap` +const ESCAPED_CHARS_MAP: Record = { + "\t": "\\t", + "\v": "\\v", + "\f": "\\f", + "\b": "\\b", + "\r": "\\r", + "\n": "\\n", + "\\": "\\\\", + '"': '\\"', + "\u2028": "\\u2028", // line separator + "\u2029": "\\u2029", // paragraph separator + "\u0085": "\\u0085", // next line +}; - return restSegments.reduce((acc, segment, index, original) => { - // Skip `properties` items when in the middle of the pointer - // See: https://github.com/openapi-ts/openapi-typescript/issues/1742 - if (segment === "properties") { - return acc; +/** + * Render a TypeScript string literal the way the printer does for a synthesized + * `ts.factory.createStringLiteral`. + * + * That is `escapeNonAsciiString`: apply TypeScript’s escape map (which covers + * `\`, `"`, the C0 controls, U+2028, U+2029 and U+0085), then escape every + * remaining code unit above U+007F as `\uXXXX`. Without the second pass, + * `"emoji🎉"` would be emitted raw where the compiler emits + * `"emoji\uD83C\uDF89"`. + * + * Note this is deliberately *not* used by {@link tsLiteral}, which mirrors the + * legacy `createIdentifier(JSON.stringify(value))` workaround for + * https://github.com/microsoft/TypeScript/issues/36174 and therefore keeps + * non-ASCII characters verbatim. + */ +function tsStringLiteral(value: string): string { + let out = '"'; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + const char = value[i]; + if (code === 0) { + // TypeScript emits `\x00` when a digit follows, so the escape cannot be + // misread as an octal escape plus that digit + const lookAhead = value.charCodeAt(i + 1); + out += lookAhead >= 48 && lookAhead <= 57 ? "\\x00" : "\\0"; + } else if (ESCAPED_CHARS_MAP[char] !== undefined) { + out += ESCAPED_CHARS_MAP[char]; + } else if (code <= 0x1f || code > 0x7f) { + out += encodeUtf16EscapeSequence(code); + } else { + out += char; } + } + return `${out}"`; +} - if (parametersObject && index === original.length - 1) { - return addIndexedAccess(acc, resolved.in, resolved.name); +/** Create a literal type */ +export function tsLiteral(value: unknown, indent = ""): TSNode { + if (typeof value === "string") { + // workaround for UTF-8: https://github.com/microsoft/TypeScript/issues/36174 + return JSON.stringify(value); + } + if (typeof value === "number") { + return renderNumberLiteral(value); + } + if (typeof value === "boolean") { + return value === true ? TRUE : FALSE; + } + if (value === null) { + return NULL; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return `${NEVER}[]`; } - - // If this segment is in the extractProperties list, - // wrap the current type with Extract before accessing. - // This narrows union types to variants that have this property. - if (extractSet.has(segment)) { - const narrowedType = wrapWithExtract(acc, segment); - return addIndexedAccess(narrowedType, segment); + const elementIndent = `${indent}${INDENT}`; + return tupleType( + value.map((v: unknown) => tsLiteral(v, elementIndent)), + indent, + ); + } + if (typeof value === "object") { + const memberIndent = `${indent}${INDENT}`; + const keys: TSNode[] = []; + for (const [k, v] of Object.entries(value)) { + keys.push( + propertySignature({ + name: tsPropertyIndex(k), + type: tsLiteral(v, memberIndent), + indent: memberIndent, + }), + ); } + return keys.length ? typeLiteral(keys, indent) : tsRecord(STRING, NEVER); + } + return UNKNOWN; +} - return addIndexedAccess(acc, segment); - }, leadingType); +/** Create a T | null union */ +export function tsNullable(types: TSNode[]): TSNode { + return [...types.map(tsParenthesize), NULL].join(" | "); } -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, - ); +/** Create a TS Omit type */ +export function tsOmit(type: TSNode, keys: string[]): TSNode { + return `Omit<${type}, ${tsUnion(keys.map((k) => tsLiteral(k)))}>`; +} - // @ts-expect-error it’s OK to overwrite statements once - sourceFile.statements = ts.factory.createNodeArray(Array.isArray(ast) ? ast : [ast]); +/** Create a TS Record type */ +export function tsRecord(key: TSNode, value: TSNode): TSNode { + return `Record<${key}, ${value}>`; +} - const printer = ts.createPrinter({ - newLine: ts.NewLineKind.LineFeed, - removeComments: false, - ...options?.formatOptions, - }); - return printer.printFile(sourceFile); +/** Create a valid property index */ +export function tsPropertyIndex(index: string | number): string { + if ( + (typeof index === "number" && !(index < 0)) || + (typeof index === "string" && String(Number(index)) === index && index[0] !== "-") + ) { + return String(index); + } + return typeof index === "string" && JS_PROPERTY_INDEX_RE.test(index) ? index : tsStringLiteral(String(index)); +} + +/** Create a union type */ +export function tsUnion(types: TSNode[]): TSNode { + if (types.length === 0) { + return NEVER; + } + if (types.length === 1) { + return types[0]; + } + return tsDedupe(types).map(tsParenthesize).join(" | "); } -/** 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; +/** Create an intersection type */ +export function tsIntersection(types: TSNode[]): TSNode { + if (types.length === 0) { + return NEVER; + } + if (types.length === 1) { + return types[0]; + } + return tsDedupe(types).map(tsParenthesize).join(" & "); } /** - * Deduplicate simple primitive types from an array of nodes - * Note: won’t deduplicate complex types like objects + * Wrap an expression in parentheses when it is a type form that binds looser + * than its enclosing construct. + * + * Reproduces the TypeScript printer, which parenthesizes unions, intersections, + * function types and conditional types when they are a member of another + * union/intersection, an array element type, or the operand of an operator such + * as `readonly`. Without this, `(a: string) => number | null` would parse as + * `(a: string) => (number | null)`. */ -export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] { - const encounteredTypes = new Set(); - 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; +export function tsParenthesize(expression: TSNode): TSNode { + return needsParentheses(expression) ? `(${expression})` : expression; +} + +/** Skip a `"…"` or `'…'` literal, returning the index of its closing quote. */ +function skipQuoted(expression: TSNode, start: number, quote: string): number { + for (let i = start + 1; i < expression.length; i++) { + const ch = expression[i]; + if (ch === "\\") { + i++; + continue; + } + if (ch === quote) { + return i; + } + } + return expression.length - 1; +} + +/** Skip a `` `…` `` template literal (including `${…}` substitutions). */ +function skipTemplate(expression: TSNode, start: number): number { + for (let i = start + 1; i < expression.length; i++) { + const ch = expression[i]; + if (ch === "\\") { + i++; + continue; + } + if (ch === "$" && expression[i + 1] === "{") { + let braceDepth = 1; + i += 2; + while (i < expression.length && braceDepth > 0) { + if (expression[i] === "{") { + braceDepth++; + } else if (expression[i] === "}") { + braceDepth--; + } + i++; + } + i--; + continue; + } + if (ch === "`") { + return i; + } + } + return expression.length - 1; +} + +/** + * Does this expression contain a union, intersection, function type or + * conditional type at nesting depth 0? + * + * Strings, template literals, comments and bracketed groups are skipped so that, + * e.g., `Omit` is not mistaken for a union, and prose inside a + * generated JSDoc block (which may contain apostrophes or braces) cannot + * unbalance the scan. `=>` is consumed as a single token so that its `>` cannot + * unbalance the angle-bracket depth. + */ +function needsParentheses(expression: TSNode): boolean { + let depth = 0; + for (let i = 0; i < expression.length; i++) { + const ch = expression[i]; + if (ch === '"' || ch === "'") { + i = skipQuoted(expression, i, ch); + continue; + } + if (ch === "`") { + i = skipTemplate(expression, i); + continue; + } + if (ch === "/" && expression[i + 1] === "*") { + const end = expression.indexOf("*/", i + 2); + i = end === -1 ? expression.length - 1 : end + 1; + continue; + } + if (ch === "/" && expression[i + 1] === "/") { + const end = expression.indexOf("\n", i + 2); + i = end === -1 ? expression.length - 1 : end; + continue; + } + if (ch === "=" && expression[i + 1] === ">") { + // a function type is not bracketed, so it always needs parentheses here + if (depth === 0) { + return true; } - if (tsIsPrimitive(t)) { - encounteredTypes.add(kind); + i++; // consume the `>`, which does not close an angle bracket + continue; + } + if (ch === "{" || ch === "[" || ch === "(" || ch === "<") { + depth++; + continue; + } + if (ch === "}" || ch === "]" || ch === ")" || ch === ">") { + depth--; + continue; + } + if (depth !== 0) { + continue; + } + if (ch === "|" || ch === "&") { + return true; + } + // conditional type: `T extends U ? X : Y` + if (ch === "e" && expression.startsWith("extends", i)) { + const before = i === 0 ? "" : expression[i - 1]; + const after = expression[i + 7] ?? ""; + if (!/[\w$]/.test(before) && !/[\w$]/.test(after)) { + return true; } } - filteredTypes.push(t); } - return filteredTypes; + return false; } -export const enumCache = new Map(); +// --------------------------------------------------------------------------- +// Line fragments +// --------------------------------------------------------------------------- + +export interface PropertySignatureOptions { + /** Already-rendered property name (see {@link tsPropertyIndex}) */ + name: string; + /** Expression fragment rendered at `indent` */ + type: TSNode; + optional?: boolean; + readonly?: boolean; + /** Comment block returned by {@link addJSDocComment} */ + comment?: string; + indent: string; +} -/** Create a TS enum (with sanitized name and members) */ -export function tsEnum( +/** A `name: type;` property signature, with optional `readonly` / `?` / JSDoc. */ +export function propertySignature({ + name, + type, + optional, + readonly, + comment = "", + indent, +}: PropertySignatureOptions): TSNode { + return `${comment}${indent}${readonly ? "readonly " : ""}${name}${optional ? "?" : ""}: ${type};`; +} + +export interface IndexSignatureOptions { + keyName: string; + /** Key type rendered at `indent` (default: `string`) */ + keyType?: TSNode; + /** Value type rendered at `indent` */ + valueType: TSNode; + readonly?: boolean; + indent: string; +} + +/** An `[key: string]: value;` index signature. */ +export function indexSignature({ + keyName, + keyType = STRING, + valueType, + readonly, + indent, +}: IndexSignatureOptions): TSNode { + return `${indent}${readonly ? "readonly " : ""}[${keyName}: ${keyType}]: ${valueType};`; +} + +export interface DeclarationOptions { + export?: boolean; + /** Comment block returned by {@link addJSDocComment} */ + comment?: string; + indent: string; +} + +/** An `export type Name = Type;` alias. */ +export function typeAlias( name: string, - members: (string | number)[], - metadata?: { name?: string; description?: string | null }[], - options?: { export?: boolean; shouldCache?: boolean }, -) { - let enumName = sanitizeMemberName(name); - enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`; - let key = ""; - if (options?.shouldCache) { - key = `${members - .slice(0) - .sort() - .map((v, i) => { - return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`; - }) - .join(",")}`; - if (enumCache.has(key)) { - return enumCache.get(key) as ts.EnumDeclaration; - } - } - const enumDeclaration = ts.factory.createEnumDeclaration( - /* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined, - /* name */ enumName, - /* members */ members.map((value, i) => tsEnumMember(value, metadata?.[i])), - ); - options?.shouldCache && enumCache.set(key, enumDeclaration); - return enumDeclaration; + type: TSNode, + { export: isExport, comment = "", indent }: DeclarationOptions, +): TSNode { + return `${comment}${indent}${isExport ? "export " : ""}type ${name} = ${type};`; } -/** Create an exported TS array literal expression */ +/** An `export interface Name { ... }` declaration. Members are line fragments. */ +export function interfaceDecl( + name: string, + members: TSNode[], + { export: isExport, comment = "", indent }: DeclarationOptions, +): TSNode { + const head = `${comment}${indent}${isExport ? "export " : ""}interface ${name}`; + return members.length ? `${head} {\n${members.join("\n")}\n${indent}}` : `${head} {\n${indent}}`; +} + +/** An `export enum Name { ... }` declaration. Members are un-indented fragments. */ +export function enumDecl( + name: string, + members: TSNode[], + { export: isExport, comment = "", indent }: DeclarationOptions, +): TSNode { + const head = `${comment}${indent}${isExport ? "export " : ""}enum ${name}`; + if (!members.length) { + return `${head} {\n${indent}}`; + } + const memberIndent = `${indent}${INDENT}`; + const body = members + .map((m) => + m + .split("\n") + .map((line) => `${memberIndent}${line}`) + .join("\n"), + ) + .join(",\n"); + return `${head} {\n${body}\n${indent}}`; +} + +/** Create an exported TS array literal expression */ export function tsArrayLiteralExpression( name: string, - elementType: ts.TypeNode, + elementType: TSNode, values: (string | number)[], - options?: { export?: boolean; readonly?: boolean; injectFooter?: ts.Node[] }, -) { + options?: { export?: boolean; readonly?: boolean; injectFooter?: FooterDeclaration[]; indent?: string }, +): TSNode { let variableName = sanitizeMemberName(name); variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`; - if ( - options?.injectFooter && - !options.injectFooter.some( - (node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "FlattenedDeepRequired", - ) - ) { - const helper = stringToAST( - "type FlattenedDeepRequired = { [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; };", - )[0] as any; - options.injectFooter.push(helper); + if (options?.injectFooter && !footerIncludes(options.injectFooter, "type FlattenedDeepRequired<")) { + options.injectFooter.push(HELPER_FLATTENED_DEEP_REQUIRED); } const arrayType = options?.readonly ? tsReadonlyArray(elementType, options.injectFooter) - : ts.factory.createArrayTypeNode(elementType); - - return ts.factory.createVariableStatement( - options ? tsModifiers({ export: options.export ?? false }) : undefined, - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - variableName, - undefined, - arrayType, - ts.factory.createArrayLiteralExpression( - values.map((value) => { - if (typeof value === "number") { - if (value < 0) { - return ts.factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - ts.factory.createNumericLiteral(Math.abs(value)), - ); - } else { - return ts.factory.createNumericLiteral(value); - } - } else { - return ts.factory.createStringLiteral(value); - } - }), - ), - ), - ], - ts.NodeFlags.Const, - ), - ); + : `${tsParenthesize(elementType)}[]`; + + const literal = values + .map((value) => (typeof value === "number" ? renderNumberLiteral(value) : tsStringLiteral(value))) + .join(", "); + + const indent = options?.indent ?? ""; + return `${indent}${options?.export ? "export " : ""}const ${variableName}: ${arrayType} = [${literal}];`; } function sanitizeMemberName(name: string) { @@ -426,175 +657,114 @@ export function tsEnumMember(value: string | number, metadata: { name?: string; } } - let member: ts.EnumMember; - if (typeof value === "number") { - const literal = - value < 0 - ? ts.factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - ts.factory.createNumericLiteral(Math.abs(value)), - ) - : ts.factory.createNumericLiteral(value); - - member = ts.factory.createEnumMember(name, literal); - } else { - member = ts.factory.createEnumMember(name, ts.factory.createStringLiteral(value)); - } + const literal = typeof value === "number" ? renderNumberLiteral(value) : tsStringLiteral(value); + const member = `${name} = ${literal}`; const trimmedDescription = metadata.description?.trim(); if (trimmedDescription === undefined || trimmedDescription === null || trimmedDescription === "") { return member; } - return ts.addSyntheticLeadingComment(member, ts.SyntaxKind.SingleLineCommentTrivia, ` ${trimmedDescription}`, true); -} + // `//` comments end at the first line break, so a multi-line description would + // otherwise leak a bare token into the enum body and produce invalid TypeScript + const description = trimmedDescription.replace(COMMENT_LB_RE, " "); -/** 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)); + // equivalent of ts.addSyntheticLeadingComment(member, SingleLineCommentTrivia, ` ${desc}`, true) + return `// ${description}\n${member}`; } -/** 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)) - ); -} +export type EnumResult = { name: string; declaration: TSNode }; -/** Create a literal type */ -export function tsLiteral(value: unknown): ts.TypeNode { - if (typeof value === "string") { - // workaround for UTF-8: https://github.com/microsoft/TypeScript/issues/36174 - return ts.factory.createIdentifier(JSON.stringify(value)) as unknown as ts.TypeNode; - } - if (typeof value === "number") { - const literal = - value < 0 - ? ts.factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - ts.factory.createNumericLiteral(Math.abs(value)), - ) - : ts.factory.createNumericLiteral(value); - return ts.factory.createLiteralTypeNode(literal); - } - 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), - ), - ); +export const enumCache = new Map(); + +/** Create a TS enum (with sanitized name and members) */ +export function tsEnum( + name: string, + members: (string | number)[], + metadata?: { name?: string; description?: string | null }[], + options?: { export?: boolean; shouldCache?: boolean; indent?: string }, +): EnumResult { + let enumName = sanitizeMemberName(name); + enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`; + let key = ""; + if (options?.shouldCache) { + key = `${members + .slice(0) + .sort() + .map((v, i) => { + return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`; + }) + .join(",")}`; + if (enumCache.has(key)) { + return enumCache.get(key) as EnumResult; } - return keys.length ? ts.factory.createTypeLiteralNode(keys) : tsRecord(STRING, NEVER); } - return UNKNOWN; + const result: EnumResult = { + name: enumName, + declaration: enumDecl( + enumName, + members.map((value, i) => tsEnumMember(value, metadata?.[i])), + { export: options?.export ?? false, indent: options?.indent ?? "" }, + ), + }; + options?.shouldCache && enumCache.set(key, result); + return result; } -/** 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; -} +const HELPER_FLATTENED_DEEP_REQUIRED = + "type FlattenedDeepRequired = {\n [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>;\n};"; +const HELPER_WITH_REQUIRED = "type WithRequired = T & {\n [P in K]-?: T[P];\n};"; +const HELPER_READONLY_ARRAY = + "type ReadonlyArray = [\n Exclude\n] extends [\n unknown[]\n] ? Readonly> : Readonly[]>;"; -/** Create a T | null union */ -export function tsNullable(types: ts.TypeNode[]): ts.TypeNode { - return ts.factory.createUnionTypeNode([...types, NULL]); -} +/** + * An injected footer entry: either an already-rendered declaration, or a + * deferred one that only becomes renderable once generation is complete. + */ +export type FooterDeclaration = TSNode | OperationsDeclaration; -/** Create a TS Omit 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))), - ]); +function footerIncludes(injectFooter: FooterDeclaration[], needle: string): boolean { + return injectFooter.some((declaration) => typeof declaration === "string" && declaration.includes(needle)); } -/** Create a TS Record type */ -export function tsRecord(key: ts.TypeNode, value: ts.TypeNode) { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Record"), [key, value]); -} +/** + * Mutable holder for the top-level `operations` interface, which is filled in + * incrementally as operations are discovered. It is rendered at assembly time so + * the declaration keeps its original position among the injected footer types. + */ +export class OperationsDeclaration { + members: TSNode[] = []; -/** 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); + add(member: TSNode): void { + this.members.push(member); } - 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]; + render(): TSNode { + return interfaceDecl("operations", this.members, { export: true, indent: "" }); } - return ts.factory.createUnionTypeNode(tsDedupe(types)); +} + +/** Render a footer entry, resolving deferred declarations. */ +export function renderFooterDeclaration(declaration: FooterDeclaration): TSNode { + return typeof declaration === "string" ? declaration : declaration.render(); } /** Create a WithRequired type */ export function tsWithRequired( - type: ts.TypeNode, + type: TSNode, keys: string[], - injectFooter: ts.Node[], // needed to inject type helper if used -): ts.TypeNode { + injectFooter: FooterDeclaration[], // needed to inject type helper if used +): TSNode { 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 & { [P in K]-?: T[P] };")[0] as any; - injectFooter.push(helper); + if (!footerIncludes(injectFooter, "type WithRequired<")) { + injectFooter.push(HELPER_WITH_REQUIRED); } - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("WithRequired"), [ - type, - tsUnion(keys.map((k) => tsLiteral(k))), - ]); + return `WithRequired<${type}, ${tsUnion(keys.map((k) => tsLiteral(k)))}>`; } /** @@ -602,15 +772,134 @@ export function tsWithRequired( * eg: type Foo = ReadonlyArray; type Bar = ReadonlyArray * Foo and Bar are both of type `readonly T[]` */ -export function tsReadonlyArray(type: ts.TypeNode, injectFooter?: ts.Node[]): ts.TypeNode { - if ( - injectFooter && - !injectFooter.some((node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "ReadonlyArray") - ) { - const helper = stringToAST( - "type ReadonlyArray = [Exclude] extends [unknown[]] ? Readonly> : Readonly[]>;", - )[0] as any; - injectFooter.push(helper); +export function tsReadonlyArray(type: TSNode, injectFooter?: FooterDeclaration[]): TSNode { + if (injectFooter && !footerIncludes(injectFooter, "type ReadonlyArray<")) { + injectFooter.push(HELPER_READONLY_ARRAY); } - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("ReadonlyArray"), [type]); + return `ReadonlyArray<${type}>`; +} + +// --------------------------------------------------------------------------- +// $ref → indexed access +// --------------------------------------------------------------------------- + +function isOasRef(obj: Referenced): obj is OasRef { + return Boolean((obj as OasRef).$ref); +} +type OapiRefResolved = Referenced; + +function isParameterObject(obj: OapiRefResolved | undefined): obj is ParameterObject { + return Boolean(obj && !isOasRef(obj) && obj.in); +} + +/** `[segment]` indexed access, with numeric segments left unquoted. */ +function addIndexedAccess(node: TSNode, ...segments: readonly (string | number)[]): TSNode { + return segments.reduce( + (acc, segment) => `${acc}[${typeof segment === "number" ? String(segment) : tsStringLiteral(segment)}]`, + node, + ); +} + +/** + * Wrap a type with `Extract` to narrow a union + * type before accessing a property that only exists on some variants. + */ +function wrapWithExtract(type: TSNode, propertyName: string, indent: string): TSNode { + const member = propertySignature({ + name: propertyName, + type: UNKNOWN, + indent: `${indent}${INDENT}`, + }); + return `Extract<${type}, ${typeLiteral([member], indent)}>`; +} + +export interface OapiRefOptions { + /** Whether to wrap with FlattenedDeepRequired<> (default: false) */ + deep?: boolean; + /** Array of property names to wrap with Extract<> when accessing */ + extractProperties?: string[]; + /** Indentation of the line this reference is rendered on (default: "") */ + indent?: string; +} + +/** + * Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) + * `path` is a JSON Pointer to a location within an OpenAPI document. + * Transform it into a TypeScript type reference into the generated types. + * + * In most cases the structures of the openapi-typescript generated types and the + * JSON Pointer paths into the OpenAPI document are the same. However, in some cases + * special transformations are necessary to account for the ways they differ. + * * Object schemas + * $refs into the `properties` of object schemas are valid, but openapi-typescript + * flattens these objects, so we omit so the index into the schema skips ["properties"] + * * Parameters + * $refs into the `parameters` of paths are valid, but openapi-ts represents + * them according to their type; path, query, header, etc… so in these cases we + * must check the parameter definition to determine the how to index into + * the openapi-typescript type. + * * Union variant properties (oneOf/anyOf) + * When accessing properties that may only exist on some variants of a union type, + * we use Extract<> to narrow the type before each property access. + **/ +export function oapiRef(path: string, resolved?: OapiRefResolved, options: OapiRefOptions = {}): TSNode { + const { pointer } = parseRef(path); + if (pointer.length === 0) { + throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`); + } + + const indent = options.indent ?? ""; + const parametersObject = isParameterObject(resolved); + const extractSet = new Set(options.extractProperties ?? []); + + // Initial segments are handled in a fixed , then remaining segments are treated + // according to heuristics based on the initial segments + const initialSegment = pointer[0]; + const leadingSegments = pointer.slice(1, 3); + const restSegments = pointer.slice(3); + + const leadingType = addIndexedAccess( + options.deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment), + ...leadingSegments, + ); + + return restSegments.reduce((acc, segment, index, original) => { + // Skip `properties` items when in the middle of the pointer + // See: https://github.com/openapi-ts/openapi-typescript/issues/1742 + if (segment === "properties") { + return acc; + } + + if (parametersObject && index === original.length - 1) { + return addIndexedAccess(acc, resolved.in, resolved.name); + } + + // If this segment is in the extractProperties list, + // wrap the current type with Extract before accessing. + // This narrows union types to variants that have this property. + if (extractSet.has(segment)) { + const narrowedType = wrapWithExtract(acc, segment, indent); + return addIndexedAccess(narrowedType, segment); + } + + return addIndexedAccess(acc, segment); + }, leadingType); +} + +export interface AstToStringOptions { + fileName?: string; + sourceText?: string; + formatOptions?: unknown; +} + +/** + * Normalize generated source into a complete file body. + * + * Generation now emits strings directly, so this only joins a list of top-level + * declarations and guarantees exactly one trailing newline. It remains exported + * for compatibility with code written against the AST-based API. + */ +export function astToString(ast: TSNode | TSNode[], _options?: AstToStringOptions): string { + const text = Array.isArray(ast) ? ast.join("\n") : ast; + return text.endsWith("\n") ? text : `${text}\n`; } diff --git a/packages/openapi-typescript/src/lib/utils.ts b/packages/openapi-typescript/src/lib/utils.ts index 49c192422..2dde624fe 100644 --- a/packages/openapi-typescript/src/lib/utils.ts +++ b/packages/openapi-typescript/src/lib/utils.ts @@ -1,9 +1,8 @@ import { escapePointer, parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; import c from "ansi-colors"; import supportsColor from "supports-color"; -import ts from "typescript"; import type { DiscriminatorObject, OpenAPI3, OpenAPITSOptions, ReferenceObject, SchemaObject } from "../types.js"; -import { tsLiteral, tsModifiers, tsPropertyIndex } from "./ts.js"; +import { propertySignature, type TSNode, tsLiteral, tsPropertyIndex } from "./ts.js"; if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) { c.enabled = false; @@ -21,8 +20,8 @@ export { c }; /** Given a discriminator object, get the property name */ export function createDiscriminatorProperty( discriminator: DiscriminatorObject, - { path, readonly = false }: { path: string; readonly?: boolean }, -): ts.TypeElement { + { path, readonly = false, indent = "" }: { path: string; readonly?: boolean; indent?: string }, +): TSNode { // get the inferred propertyName value from the last section of the path (as the spec suggests to do) let value = parseRef(path).pointer.pop(); // if mapping, and there’s a match, use this rather than the inferred name @@ -35,14 +34,12 @@ export function createDiscriminatorProperty( value = matchedValue[0]; // why was this designed backwards!? } } - return ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ - readonly, - }), - /* name */ tsPropertyIndex(discriminator.propertyName), - /* questionToken */ undefined, - /* type */ tsLiteral(value), - ); + return propertySignature({ + /* name */ name: tsPropertyIndex(discriminator.propertyName), + /* type */ type: tsLiteral(value), + /* modifiers */ readonly, + indent, + }); } /** Create a $ref pointer (even from other $refs) */ diff --git a/packages/openapi-typescript/src/transform/components-object.ts b/packages/openapi-typescript/src/transform/components-object.ts index 0a5bdecac..2c57bd5fc 100644 --- a/packages/openapi-typescript/src/transform/components-object.ts +++ b/packages/openapi-typescript/src/transform/components-object.ts @@ -1,7 +1,15 @@ import { performance } from "node:perf_hooks"; import * as changeCase from "change-case"; -import ts from "typescript"; -import { addJSDocComment, NEVER, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + propertySignature, + type TSNode, + tsPropertyIndex, + typeAlias, + typeLiteral, +} from "../lib/ts.js"; import { createRef, debug, getEntries } from "../lib/utils.js"; import type { ComponentsObject, GlobalContext, SchemaObject, TransformNodeOptions } from "../types.js"; import transformHeaderObject from "./header-object.js"; @@ -41,33 +49,44 @@ export function isEnumSchema(schema: unknown): boolean { type ComponentTransforms = keyof Omit; -const transformers: Record ts.TypeNode> = { - schemas: transformSchemaObject, - responses: transformResponseObject, - parameters: transformParameterObject, - requestBodies: transformRequestBodyObject, - headers: transformHeaderObject, - pathItems: transformPathItemObject, -}; +const transformers: Record TSNode> = + { + schemas: (node, options, indent) => transformSchemaObject(node, options, false, indent), + responses: transformResponseObject, + parameters: transformParameterObject, + requestBodies: transformRequestBodyObject, + headers: transformHeaderObject, + pathItems: transformPathItemObject, + }; /** * Transform the ComponentsObject (4.8.7) * @see https://spec.openapis.org/oas/latest.html#components-object */ -export default function transformComponentsObject(componentsObject: ComponentsObject, ctx: GlobalContext): ts.Node[] { - const type: ts.TypeElement[] = []; - const rootTypeAliases: { [key: string]: ts.TypeAliasDeclaration } = {}; +export default function transformComponentsObject( + componentsObject: ComponentsObject, + ctx: GlobalContext, + indent = "", +): TSNode[] { + const memberIndent = `${indent}${INDENT}`; + const itemIndent = `${memberIndent}${INDENT}`; + const type: TSNode[] = []; + const rootTypeAliases: { [key: string]: TSNode } = {}; for (const key of Object.keys(transformers) as ComponentTransforms[]) { const componentT = performance.now(); - const items: ts.TypeElement[] = []; + const items: TSNode[] = []; if (componentsObject[key]) { for (const [name, item] of getEntries(componentsObject[key], ctx)) { - let subType = transformers[key](item, { - path: createRef(["components", key, name]), - schema: item, - ctx, - }); + let subType = transformers[key]( + item, + { + path: createRef(["components", key, name]), + schema: item, + ctx, + }, + itemIndent, + ); let hasQuestionToken = false; if (ctx.transform) { @@ -77,23 +96,25 @@ export default function transformComponentsObject(componentsObject: ComponentsOb ctx, }); if (result) { - if ("schema" in result) { + if (typeof result === "object" && "schema" in result) { subType = result.schema; hasQuestionToken = result.questionToken; } else { - subType = result; + subType = result as TSNode; } } } - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: ctx.immutable }), - /* name */ tsPropertyIndex(name), - /* questionToken */ hasQuestionToken ? QUESTION_TOKEN : undefined, - /* type */ subType, + items.push( + propertySignature({ + /* modifiers */ readonly: ctx.immutable, + /* name */ name: tsPropertyIndex(name), + /* questionToken */ optional: hasQuestionToken, + /* type */ type: subType, + comment: addJSDocComment(item as unknown as any, itemIndent), + indent: itemIndent, + }), ); - addJSDocComment(item as unknown as any, property); - items.push(property); if (ctx.rootTypes) { // Skip enum schemas when generating root types to prevent duplication (only when --enum flag is enabled) @@ -111,40 +132,33 @@ export default function transformComponentsObject(componentsObject: ComponentsOb conflictCounter++; aliasName = `${componentKey}${componentName}_${conflictCounter}`; } - const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`); + const ref = `components['${key}']['${name}']`; if (ctx.rootTypesNoSchemaPrefix && key === "schemas") { aliasName = aliasName.replace(componentKey, ""); } - const typeAlias = ts.factory.createTypeAliasDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ aliasName, - /* typeParameters */ undefined, - /* type */ ref, - ); - rootTypeAliases[aliasName] = typeAlias; + rootTypeAliases[aliasName] = typeAlias(aliasName, ref, { + /* modifiers */ export: true, + indent: "", + }); } } } } type.push( - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex(key), - /* questionToken */ undefined, - /* type */ items.length ? ts.factory.createTypeLiteralNode(items) : NEVER, - ), + propertySignature({ + /* name */ name: tsPropertyIndex(key), + /* type */ type: items.length ? typeLiteral(items, memberIndent) : NEVER, + indent: memberIndent, + }), ); debug(`Transformed components → ${key}`, "ts", performance.now() - componentT); } // Extract root types - let rootTypes: ts.TypeAliasDeclaration[] = []; - if (ctx.rootTypes) { - rootTypes = Object.keys(rootTypeAliases).map((k) => rootTypeAliases[k]); - } + const rootTypes: TSNode[] = ctx.rootTypes ? Object.keys(rootTypeAliases).map((k) => rootTypeAliases[k]) : []; - return [ts.factory.createTypeLiteralNode(type), ...rootTypes]; + return [typeLiteral(type, indent), ...rootTypes]; } export function singularizeComponentKey( diff --git a/packages/openapi-typescript/src/transform/header-object.ts b/packages/openapi-typescript/src/transform/header-object.ts index cb3654e5b..d959d5c99 100644 --- a/packages/openapi-typescript/src/transform/header-object.ts +++ b/packages/openapi-typescript/src/transform/header-object.ts @@ -1,6 +1,13 @@ import { escapePointer } from "@redocly/openapi-core/lib/ref-utils.js"; -import ts from "typescript"; -import { addJSDocComment, tsModifiers, tsPropertyIndex, UNKNOWN } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, + UNKNOWN, +} from "../lib/ts.js"; import { getEntries } from "../lib/utils.js"; import type { HeaderObject, TransformNodeOptions } from "../types.js"; import transformMediaTypeObject from "./media-type-object.js"; @@ -10,35 +17,35 @@ import transformSchemaObject from "./schema-object.js"; * Transform HeaderObject nodes (4.8.21) * @see https://spec.openapis.org/oas/v3.1.0#header-object */ -export default function transformHeaderObject(headerObject: HeaderObject, options: TransformNodeOptions): ts.TypeNode { +export default function transformHeaderObject( + headerObject: HeaderObject, + options: TransformNodeOptions, + indent = "", +): TSNode { if (headerObject.schema) { - return transformSchemaObject(headerObject.schema, options); + return transformSchemaObject(headerObject.schema, options, false, indent); } if (headerObject.content) { - const type: ts.TypeElement[] = []; + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; for (const [contentType, mediaTypeObject] of getEntries(headerObject.content ?? {}, options.ctx)) { const nextPath = `${options.path ?? "#"}/${escapePointer(contentType)}`; const mediaType = "$ref" in mediaTypeObject - ? transformSchemaObject(mediaTypeObject, { - ...options, - path: nextPath, - }) - : transformMediaTypeObject(mediaTypeObject, { - ...options, - path: nextPath, - }); - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(contentType), - /* questionToken */ undefined, - /* type */ mediaType, + ? transformSchemaObject(mediaTypeObject, { ...options, path: nextPath }, false, memberIndent) + : transformMediaTypeObject(mediaTypeObject, { ...options, path: nextPath }, memberIndent); + type.push( + propertySignature({ + /* name */ name: tsPropertyIndex(contentType), + /* type */ type: mediaType, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(mediaTypeObject, memberIndent), + indent: memberIndent, + }), ); - addJSDocComment(mediaTypeObject, property); - type.push(property); } - return ts.factory.createTypeLiteralNode(type); + return typeLiteral(type, indent); } return UNKNOWN; diff --git a/packages/openapi-typescript/src/transform/index.ts b/packages/openapi-typescript/src/transform/index.ts index ad4af119a..b7420127f 100644 --- a/packages/openapi-typescript/src/transform/index.ts +++ b/packages/openapi-typescript/src/transform/index.ts @@ -1,6 +1,14 @@ import { performance } from "node:perf_hooks"; -import ts, { type InterfaceDeclaration, type TypeLiteralNode } from "typescript"; -import { NEVER, STRING, stringToAST, tsModifiers, tsRecord } from "../lib/ts.js"; +import { + interfaceDecl, + NEVER, + OperationsDeclaration, + renderFooterDeclaration, + STRING, + type TSNode, + tsRecord, + typeAlias, +} from "../lib/ts.js"; import { createRef, debug } from "../lib/utils.js"; import type { GlobalContext, OpenAPI3 } from "../types.js"; import transformComponentsObject from "./components-object.js"; @@ -11,72 +19,79 @@ import transformWebhooksObject from "./webhooks-object.js"; type SchemaTransforms = keyof Pick; -const transformers: Record ts.Node | ts.Node[]> = { +const transformers: Record TSNode | TSNode[]> = { paths: transformPathsObject, webhooks: transformWebhooksObject, components: transformComponentsObject, $defs: (node, options) => transformSchemaObject(node, { path: createRef(["$defs"]), ctx: options, schema: node }), }; +/** + * Extract the members of a top-level object type expression. + * Returns `undefined` when the expression is not an object type (in which case + * the root falls back to `Record`), mirroring the legacy + * `.members?.length` check on the generated AST node. + */ +function typeLiteralMembers(expression: TSNode, indent: string): TSNode[] | undefined { + const open = "{\n"; + const close = `\n${indent}}`; + if (!expression.startsWith(open) || !expression.endsWith(close) || expression.length <= open.length + close.length) { + return undefined; + } + const body = expression.slice(open.length, expression.length - close.length); + return body.length ? body.split("\n") : undefined; +} + // Inline helper types for readOnly/writeOnly markers (when readWriteMarkers is enabled) -const READ_WRITE_HELPER_TYPES = ` -export type $Read = { readonly $read: T }; -export type $Write = { readonly $write: T }; -export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends object ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } : T; -export type Writable = T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends object ? { [K in keyof T as NonNullable extends $Read ? never : K]: Writable } & { [K in keyof T as NonNullable extends $Read ? K : never]?: never } : T; -`; +const READ_WRITE_HELPER_TYPES: TSNode[] = [ + "export type $Read = {\n readonly $read: T;\n};", + "export type $Write = {\n readonly $write: T;\n};", + "export type Readable = T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends object ? {\n [K in keyof T as NonNullable extends $Write ? never : K]: Readable;\n} : T;", + "export type Writable = T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends object ? {\n [K in keyof T as NonNullable extends $Read ? never : K]: Writable;\n} & {\n [K in keyof T as NonNullable extends $Read ? K : never]?: never;\n} : T;", +]; -export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) { - const type: ts.Node[] = []; +export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext): TSNode[] { + const type: TSNode[] = []; // Add inline helper types for readOnly/writeOnly markers if (ctx.readWriteMarkers) { - const helperNodes = stringToAST(READ_WRITE_HELPER_TYPES) as ts.Node[]; - type.push(...helperNodes); + type.push(...READ_WRITE_HELPER_TYPES); } if (ctx.inject) { - const injectNodes = stringToAST(ctx.inject) as ts.Node[]; - type.push(...injectNodes); + // emitted verbatim (previously round-tripped through the TypeScript printer) + type.push(ctx.inject.trim()); } for (const root of Object.keys(transformers) as SchemaTransforms[]) { - const emptyObj = ts.factory.createTypeAliasDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ root, - /* typeParameters */ undefined, - /* type */ tsRecord(STRING, NEVER), - ); + const emptyObj = typeAlias(root, tsRecord(STRING, NEVER), { + /* modifiers */ export: true, + /* indent */ indent: "", + }); if (schema[root] && typeof schema[root] === "object") { const rootT = performance.now(); - const subTypes = ([] as ts.Node[]).concat(transformers[root](schema[root], ctx)); - for (const subType of subTypes) { - if (ts.isTypeNode(subType)) { - if ((subType as ts.TypeLiteralNode).members?.length) { - type.push( - ctx.exportType - ? ts.factory.createTypeAliasDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ root, - /* typeParameters */ undefined, - /* type */ subType, - ) - : ts.factory.createInterfaceDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ root, - /* typeParameters */ undefined, - /* heritageClauses */ undefined, - /* members */ (subType as TypeLiteralNode).members, - ), - ); - debug(`${root} done`, "ts", performance.now() - rootT); - } else { - type.push(emptyObj); - debug(`${root} done (skipped)`, "ts", 0); - } - } else if (ts.isTypeAliasDeclaration(subType)) { + const subTypes = ([] as TSNode[]).concat(transformers[root](schema[root], ctx)); + for (const [index, subType] of subTypes.entries()) { + if (index > 0) { + // extra top-level declarations (e.g. root types generated from `components`) type.push(subType); + continue; + } + const members = typeLiteralMembers(subType, ""); + if (members?.length) { + type.push( + ctx.exportType + ? typeAlias(root, subType, { + /* modifiers */ export: true, + /* indent */ indent: "", + }) + : interfaceDecl(root, members, { + /* modifiers */ export: true, + /* indent */ indent: "", + }), + ); + debug(`${root} done`, "ts", performance.now() - rootT); } else { type.push(emptyObj); debug(`${root} done (skipped)`, "ts", 0); @@ -91,20 +106,18 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) { // inject let hasOperations = false; for (const injectedType of ctx.injectFooter) { - if (!hasOperations && (injectedType as InterfaceDeclaration)?.name?.escapedText === "operations") { + if (!hasOperations && injectedType instanceof OperationsDeclaration) { hasOperations = true; } - type.push(injectedType); + type.push(renderFooterDeclaration(injectedType)); } if (!hasOperations) { // if no operations created, inject empty operations type type.push( - ts.factory.createTypeAliasDeclaration( - /* modifiers */ tsModifiers({ export: true }), - /* name */ "operations", - /* typeParameters */ undefined, - /* type */ tsRecord(STRING, NEVER), - ), + typeAlias("operations", tsRecord(STRING, NEVER), { + /* modifiers */ export: true, + /* indent */ indent: "", + }), ); } diff --git a/packages/openapi-typescript/src/transform/media-type-object.ts b/packages/openapi-typescript/src/transform/media-type-object.ts index 647febbb0..a2194f39b 100644 --- a/packages/openapi-typescript/src/transform/media-type-object.ts +++ b/packages/openapi-typescript/src/transform/media-type-object.ts @@ -1,5 +1,4 @@ -import type ts from "typescript"; -import { UNKNOWN } from "../lib/ts.js"; +import { type TSNode, UNKNOWN } from "../lib/ts.js"; import type { MediaTypeObject, TransformNodeOptions } from "../types.js"; import transformSchemaObject from "./schema-object.js"; @@ -10,9 +9,10 @@ import transformSchemaObject from "./schema-object.js"; export default function transformMediaTypeObject( mediaTypeObject: MediaTypeObject, options: TransformNodeOptions, -): ts.TypeNode { + indent = "", +): TSNode { if (!mediaTypeObject.schema) { return UNKNOWN; } - return transformSchemaObject(mediaTypeObject.schema, options); + return transformSchemaObject(mediaTypeObject.schema, options, false, indent); } diff --git a/packages/openapi-typescript/src/transform/operation-object.ts b/packages/openapi-typescript/src/transform/operation-object.ts index a178be58c..662b21f8f 100644 --- a/packages/openapi-typescript/src/transform/operation-object.ts +++ b/packages/openapi-typescript/src/transform/operation-object.ts @@ -1,5 +1,14 @@ -import ts from "typescript"; -import { addJSDocComment, NEVER, oapiRef, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + OperationsDeclaration, + oapiRef, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef } from "../lib/utils.js"; import type { OperationObject, RequestBodyObject, TransformNodeOptions } from "../types.js"; import { transformParametersArray } from "./parameters-array.js"; @@ -9,57 +18,69 @@ import transformResponsesObject from "./responses-object.js"; /** * Transform OperationObject nodes (4.8.10) * @see https://spec.openapis.org/oas/v3.1.0#operation-object + * + * Returns the *members* of the operation object type, already indented at + * `indent` (the indentation of the produced member lines). */ export default function transformOperationObject( operationObject: OperationObject, options: TransformNodeOptions, -): ts.TypeElement[] { - const type: ts.TypeElement[] = []; + indent = "", +): TSNode[] { + const memberIndent = indent; + const type: TSNode[] = []; // parameters - type.push(...transformParametersArray(operationObject.parameters ?? [], options)); + type.push(...transformParametersArray(operationObject.parameters ?? [], options, memberIndent)); // requestBody if (operationObject.requestBody) { const requestBodyType = "$ref" in operationObject.requestBody - ? oapiRef(operationObject.requestBody.$ref) - : transformRequestBodyObject(operationObject.requestBody, { - ...options, - path: createRef([options.path, "requestBody"]), - }); + ? oapiRef(operationObject.requestBody.$ref, undefined, { indent: memberIndent }) + : transformRequestBodyObject( + operationObject.requestBody, + { + ...options, + path: createRef([options.path, "requestBody"]), + }, + memberIndent, + ); const required = !!( "$ref" in operationObject.requestBody ? options.ctx.resolve(operationObject.requestBody.$ref) : operationObject.requestBody )?.required; - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex("requestBody"), - /* questionToken */ required ? undefined : QUESTION_TOKEN, - /* type */ requestBodyType, + type.push( + propertySignature({ + /* name */ name: tsPropertyIndex("requestBody"), + /* type */ type: requestBodyType, + /* questionToken */ optional: !required, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(operationObject.requestBody, memberIndent), + indent: memberIndent, + }), ); - addJSDocComment(operationObject.requestBody, property); - type.push(property); } else { type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex("requestBody"), - /* questionToken */ QUESTION_TOKEN, - /* type */ NEVER, - ), + propertySignature({ + /* name */ name: tsPropertyIndex("requestBody"), + /* questionToken */ optional: true, + /* type */ type: NEVER, + /* modifiers */ readonly: options.ctx.immutable, + indent: memberIndent, + }), ); } // responses type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex("responses"), - /* questionToken */ undefined, - /* type */ transformResponsesObject(operationObject.responses ?? {}, options), - ), + propertySignature({ + /* name */ name: tsPropertyIndex("responses"), + /* type */ type: transformResponsesObject(operationObject.responses ?? {}, options, memberIndent), + /* modifiers */ readonly: options.ctx.immutable, + indent: memberIndent, + }), ); return type; @@ -73,32 +94,21 @@ export function injectOperationObject( ): void { // find or create top-level operations interface let operations = options.ctx.injectFooter.find( - (node) => ts.isInterfaceDeclaration(node) && (node as ts.InterfaceDeclaration).name.text === "operations", - ) as unknown as ts.InterfaceDeclaration; + (declaration): declaration is OperationsDeclaration => declaration instanceof OperationsDeclaration, + ); if (!operations) { - operations = ts.factory.createInterfaceDeclaration( - /* modifiers */ tsModifiers({ - export: true, - // important: do NOT make this immutable - }), - /* name */ ts.factory.createIdentifier("operations"), - /* typeParameters */ undefined, - /* heritageClauses */ undefined, - /* members */ [], - ); + operations = new OperationsDeclaration(); options.ctx.injectFooter.push(operations); } // inject operation object - const type = transformOperationObject(operationObject, options); - // @ts-expect-error this is OK to mutate - operations.members = ts.factory.createNodeArray([ - ...operations.members, - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(operationId), - /* questionToken */ undefined, - /* type */ ts.factory.createTypeLiteralNode(type), - ), - ]); + const type = transformOperationObject(operationObject, options, `${INDENT}${INDENT}`); + operations.add( + propertySignature({ + /* modifiers */ readonly: options.ctx.immutable, + /* name */ name: tsPropertyIndex(operationId), + /* type */ type: typeLiteral(type, INDENT), + indent: INDENT, + }), + ); } diff --git a/packages/openapi-typescript/src/transform/parameter-object.ts b/packages/openapi-typescript/src/transform/parameter-object.ts index 43412e8a6..a4d67b4ee 100644 --- a/packages/openapi-typescript/src/transform/parameter-object.ts +++ b/packages/openapi-typescript/src/transform/parameter-object.ts @@ -1,5 +1,4 @@ -import type ts from "typescript"; -import { STRING } from "../lib/ts.js"; +import { STRING, type TSNode } from "../lib/ts.js"; import type { ParameterObject, TransformNodeOptions } from "../types.js"; import transformSchemaObject from "./schema-object.js"; @@ -10,6 +9,7 @@ import transformSchemaObject from "./schema-object.js"; export default function transformParameterObject( parameterObject: ParameterObject, options: TransformNodeOptions, -): ts.TypeNode { - return parameterObject.schema ? transformSchemaObject(parameterObject.schema, options) : STRING; // assume a parameter is a string by default rather than "unknown" + indent = "", +): TSNode { + return parameterObject.schema ? transformSchemaObject(parameterObject.schema, options, false, indent) : STRING; // assume a parameter is a string by default rather than "unknown" } diff --git a/packages/openapi-typescript/src/transform/parameters-array.ts b/packages/openapi-typescript/src/transform/parameters-array.ts index 78146f34e..73e094dc2 100644 --- a/packages/openapi-typescript/src/transform/parameters-array.ts +++ b/packages/openapi-typescript/src/transform/parameters-array.ts @@ -1,5 +1,13 @@ -import ts from "typescript"; -import { addJSDocComment, NEVER, oapiRef, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + oapiRef, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef } from "../lib/utils.js"; import type { ParameterObject, ReferenceObject, TransformNodeOptions } from "../types.js"; import transformParameterObject from "./parameter-object.js"; @@ -36,12 +44,17 @@ function extractPathParamsFromUrl(path: string): ParameterObject[] { /** * Synthetic type. Array of (ParameterObject | ReferenceObject)s found in OperationObject and PathItemObject. + * + * `indent` is the indentation of the produced property lines. */ export function transformParametersArray( parametersArray: (ParameterObject | ReferenceObject)[], options: TransformNodeOptions, -): ts.TypeElement[] { - const type: ts.TypeElement[] = []; + indent = "", +): TSNode[] { + const paramInIndent = `${indent}${INDENT}`; + const paramIndent = `${paramInIndent}${INDENT}`; + const type: TSNode[] = []; // Create a working copy of parameters array const workingParameters = [...parametersArray]; @@ -65,9 +78,10 @@ export function transformParametersArray( } // parameters - const paramType: ts.TypeElement[] = []; + const paramType: TSNode[] = []; for (const paramIn of ["query", "header", "path", "cookie"] as ParameterObject["in"][]) { - const paramLocType: ts.TypeElement[] = []; + const paramLocType: TSNode[] = []; + const optionals: boolean[] = []; let operationParameters = workingParameters.map((param) => ({ original: param, resolved: "$ref" in param ? options.ctx.resolve(param.$ref) : param, @@ -86,43 +100,49 @@ export function transformParametersArray( if (resolved?.in !== paramIn) { continue; } - let optional: ts.QuestionToken | undefined; - if (paramIn !== "path" && !(resolved as ParameterObject).required) { - optional = QUESTION_TOKEN; - } + const optional = paramIn !== "path" && !(resolved as ParameterObject).required; const subType = "$ref" in original - ? oapiRef(original.$ref, resolved) - : transformParameterObject(resolved as ParameterObject, { - ...options, - path: createRef([options.path, "parameters", resolved.in, resolved.name]), - }); - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(resolved?.name), - /* questionToken */ optional, - /* type */ subType, + ? oapiRef(original.$ref, resolved, { indent: paramIndent }) + : transformParameterObject( + resolved as ParameterObject, + { + ...options, + path: createRef([options.path, "parameters", resolved.in, resolved.name]), + }, + paramIndent, + ); + optionals.push(optional); + paramLocType.push( + propertySignature({ + /* name */ name: tsPropertyIndex(resolved?.name), + /* type */ type: subType, + /* questionToken */ optional, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(resolved, paramIndent), + indent: paramIndent, + }), ); - addJSDocComment(resolved, property); - paramLocType.push(property); } - const allOptional = paramLocType.every((node) => !!node.questionToken); + const allOptional = optionals.every(Boolean); paramType.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(paramIn), - /* questionToken */ allOptional || !paramLocType.length ? QUESTION_TOKEN : undefined, - /* type */ paramLocType.length ? ts.factory.createTypeLiteralNode(paramLocType) : NEVER, - ), + propertySignature({ + /* name */ name: tsPropertyIndex(paramIn), + /* type */ type: paramLocType.length ? typeLiteral(paramLocType, paramInIndent) : NEVER, + /* questionToken */ optional: allOptional || !paramLocType.length, + /* modifiers */ readonly: options.ctx.immutable, + indent: paramInIndent, + }), ); } type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex("parameters"), - /* questionToken */ !paramType.length ? QUESTION_TOKEN : undefined, - /* type */ paramType.length ? ts.factory.createTypeLiteralNode(paramType) : NEVER, - ), + propertySignature({ + /* name */ name: tsPropertyIndex("parameters"), + /* type */ type: paramType.length ? typeLiteral(paramType, indent) : NEVER, + /* questionToken */ optional: !paramType.length, + /* modifiers */ readonly: options.ctx.immutable, + indent, + }), ); return type; diff --git a/packages/openapi-typescript/src/transform/path-item-object.ts b/packages/openapi-typescript/src/transform/path-item-object.ts index 87ca02d78..3eb2f2af2 100644 --- a/packages/openapi-typescript/src/transform/path-item-object.ts +++ b/packages/openapi-typescript/src/transform/path-item-object.ts @@ -1,5 +1,13 @@ -import ts from "typescript"; -import { addJSDocComment, NEVER, oapiRef, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + oapiRef, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef } from "../lib/utils.js"; import type { OperationObject, @@ -17,15 +25,24 @@ export type Method = "get" | "put" | "post" | "delete" | "options" | "head" | "p * Transform PathItem nodes (4.8.9) * @see https://spec.openapis.org/oas/v3.1.0#path-item-object */ -export default function transformPathItemObject(pathItem: PathItemObject, options: TransformNodeOptions): ts.TypeNode { - const type: ts.TypeElement[] = []; +export default function transformPathItemObject( + pathItem: PathItemObject, + options: TransformNodeOptions, + indent = "", +): TSNode { + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; // parameters type.push( - ...transformParametersArray(pathItem.parameters ?? [], { - ...options, - path: createRef([options.path, "parameters"]), - }), + ...transformParametersArray( + pathItem.parameters ?? [], + { + ...options, + path: createRef([options.path, "parameters"]), + }, + memberIndent, + ), ); // methods @@ -38,12 +55,13 @@ export default function transformPathItemObject(pathItem: PathItemObject, option ?.deprecated) ) { type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(method), - /* questionToken */ QUESTION_TOKEN, - /* type */ NEVER, - ), + propertySignature({ + /* modifiers */ readonly: options.ctx.immutable, + /* name */ name: tsPropertyIndex(method), + /* questionToken */ optional: true, + /* type */ type: NEVER, + indent: memberIndent, + }), ); continue; } @@ -64,39 +82,42 @@ export default function transformPathItemObject(pathItem: PathItemObject, option } } - let operationType: ts.TypeNode; + let operationType: TSNode; if ("$ref" in operationObject) { - operationType = oapiRef(operationObject.$ref); + operationType = oapiRef(operationObject.$ref, undefined, { indent: memberIndent }); } // if operationId exists, move into an `operations` export and pass the reference in here else if (operationObject.operationId) { // workaround for issue caused by redocly ref parsing: https://github.com/openapi-ts/openapi-typescript/issues/1542 const operationId = operationObject.operationId.replace(HASH_RE, "/"); - operationType = oapiRef(createRef(["operations", operationId])); + operationType = oapiRef(createRef(["operations", operationId]), undefined, { indent: memberIndent }); injectOperationObject( operationId, { ...operationObject, parameters: Object.values(keyedParameters) }, { ...options, path: createRef([options.path, method]) }, ); } else { - operationType = ts.factory.createTypeLiteralNode( + operationType = typeLiteral( transformOperationObject( { ...operationObject, parameters: Object.values(keyedParameters) }, { ...options, path: createRef([options.path, method]) }, + `${memberIndent}${INDENT}`, ), + memberIndent, ); } - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(method), - /* questionToken */ undefined, - /* type */ operationType, + type.push( + propertySignature({ + /* modifiers */ readonly: options.ctx.immutable, + /* name */ name: tsPropertyIndex(method), + /* type */ type: operationType, + comment: addJSDocComment(operationObject, memberIndent), + indent: memberIndent, + }), ); - addJSDocComment(operationObject, property); - type.push(property); } - return ts.factory.createTypeLiteralNode(type); + return typeLiteral(type, indent); } const HASH_RE = /#/g; diff --git a/packages/openapi-typescript/src/transform/paths-enum.ts b/packages/openapi-typescript/src/transform/paths-enum.ts index ea2fbdb43..79eb9b947 100644 --- a/packages/openapi-typescript/src/transform/paths-enum.ts +++ b/packages/openapi-typescript/src/transform/paths-enum.ts @@ -1,9 +1,8 @@ -import type ts from "typescript"; -import { tsEnum } from "../lib/ts.js"; +import { type TSNode, tsEnum } from "../lib/ts.js"; import { getEntries } from "../lib/utils.js"; import type { PathsObject } from "../types.js"; -export default function makeApiPathsEnum(pathsObject: PathsObject): ts.EnumDeclaration { +export default function makeApiPathsEnum(pathsObject: PathsObject): TSNode { const enumKeys = []; const enumMetaData = []; @@ -39,5 +38,5 @@ export default function makeApiPathsEnum(pathsObject: PathsObject): ts.EnumDecla return tsEnum("ApiPaths", enumKeys, enumMetaData, { export: true, - }); + }).declaration; } diff --git a/packages/openapi-typescript/src/transform/paths-object.ts b/packages/openapi-typescript/src/transform/paths-object.ts index 83c36af1a..5a5bb1abf 100644 --- a/packages/openapi-typescript/src/transform/paths-object.ts +++ b/packages/openapi-typescript/src/transform/paths-object.ts @@ -1,6 +1,14 @@ import { performance } from "node:perf_hooks"; -import ts from "typescript"; -import { addJSDocComment, oapiRef, stringToAST, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + indexSignature, + oapiRef, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef, debug, getEntries } from "../lib/utils.js"; import type { GlobalContext, @@ -14,12 +22,24 @@ import transformPathItemObject, { type Method } from "./path-item-object.js"; const PATH_PARAM_RE = /\{[^}]+\}/g; +/** + * Escape a URL for use inside a template literal type (`` `…` ``). + * + * A backtick would terminate the literal, and a backslash would either introduce + * an escape sequence or (when trailing) escape the closing backtick or the `$` + * of a `${…}` substitution, silently turning the type into a plain string. + */ +function escapeTemplateLiteral(text: string): string { + return text.replace(/[`\\]/g, (match) => `\\${match}`); +} + /** * Transform the PathsObject node (4.8.8) * @see https://spec.openapis.org/oas/v3.1.0#operation-object */ -export default function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode { - const type: ts.TypeElement[] = []; +export default function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext, indent = ""): TSNode { + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) { if (!pathItemObject || typeof pathItemObject !== "object") { continue; @@ -29,25 +49,32 @@ export default function transformPathsObject(pathsObject: PathsObject, ctx: Glob // handle $ref if ("$ref" in pathItemObject) { - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: ctx.immutable }), - /* name */ tsPropertyIndex(url), - /* questionToken */ undefined, - /* type */ oapiRef(pathItemObject.$ref), + type.push( + propertySignature({ + /* modifiers */ readonly: ctx.immutable, + /* name */ name: tsPropertyIndex(url), + /* type */ type: oapiRef(pathItemObject.$ref, undefined, { indent: memberIndent }), + comment: addJSDocComment(pathItemObject, memberIndent), + indent: memberIndent, + }), ); - addJSDocComment(pathItemObject, property); - type.push(property); } else { - const pathItemType = transformPathItemObject(pathItemObject, { - path: createRef(["paths", url]), - ctx, - }); + const pathItemType = transformPathItemObject( + pathItemObject, + { + path: createRef(["paths", url]), + ctx, + }, + memberIndent, + ); // pathParamsAsTypes if (ctx.pathParamsAsTypes && url.includes("{")) { const pathParams = extractPathParams(pathItemObject, ctx); const matches = url.match(PATH_PARAM_RE); - let rawPath = `\`${url}\``; + // the URL becomes the body of a template literal type, so anything that + // could break out of it (a backtick, a backslash, or a `${`) is escaped + let rawPath = `\`${escapeTemplateLiteral(url)}\``; if (matches) { for (const match of matches) { const paramName = match.slice(1, -1); @@ -65,45 +92,35 @@ export default function transformPathsObject(pathsObject: PathsObject, ctx: Glob break; } } - // note: creating a string template literal’s AST manually is hard! - // just pass an arbitrary string to TS - const pathType = (stringToAST(rawPath)[0] as any)?.expression; - if (pathType) { - type.push( - ts.factory.createIndexSignature( - /* modifiers */ tsModifiers({ readonly: ctx.immutable }), - /* parameters */ [ - ts.factory.createParameterDeclaration( - /* modifiers */ undefined, - /* dotDotDotToken */ undefined, - /* name */ "path", - /* questionToken */ undefined, - /* type */ pathType, - /* initializer */ undefined, - ), - ], - /* type */ pathItemType, - ), - ); - continue; - } + // note: the template literal type is emitted verbatim — it used to be + // round-tripped through the TypeScript parser for exactly this reason + type.push( + indexSignature({ + /* modifiers */ readonly: ctx.immutable, + /* parameters */ keyName: "path", + /* type */ keyType: rawPath, + /* type */ valueType: pathItemType, + indent: memberIndent, + }), + ); + continue; } } type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: ctx.immutable }), - /* name */ tsPropertyIndex(url), - /* questionToken */ undefined, - /* type */ pathItemType, - ), + propertySignature({ + /* modifiers */ readonly: ctx.immutable, + /* name */ name: tsPropertyIndex(url), + /* type */ type: pathItemType, + indent: memberIndent, + }), ); debug(`Transformed path "${url}"`, "ts", performance.now() - pathT); } } - return ts.factory.createTypeLiteralNode(type); + return typeLiteral(type, indent); } function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) { diff --git a/packages/openapi-typescript/src/transform/request-body-object.ts b/packages/openapi-typescript/src/transform/request-body-object.ts index 3c641c475..01cde8637 100644 --- a/packages/openapi-typescript/src/transform/request-body-object.ts +++ b/packages/openapi-typescript/src/transform/request-body-object.ts @@ -1,5 +1,12 @@ -import ts from "typescript"; -import { addJSDocComment, NEVER, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef, getEntries } from "../lib/utils.js"; import type { RequestBodyObject, TransformNodeOptions } from "../types.js"; import transformMediaTypeObject from "./media-type-object.js"; @@ -12,48 +19,64 @@ import transformSchemaObject from "./schema-object.js"; export default function transformRequestBodyObject( requestBodyObject: RequestBodyObject, options: TransformNodeOptions, -): ts.TypeNode { - const type: ts.TypeElement[] = []; + indent = "", +): TSNode { + const memberIndent = `${indent}${INDENT}`; + const contentIndent = `${memberIndent}${INDENT}`; + const type: TSNode[] = []; for (const [contentType, mediaTypeObject] of getEntries(requestBodyObject.content ?? {}, options.ctx)) { const nextPath = createRef([options.path, "content", contentType]); const mediaType = "$ref" in mediaTypeObject - ? transformSchemaObject(mediaTypeObject, { - ...options, - path: nextPath, - }) - : transformMediaTypeObject(mediaTypeObject, { - ...options, - path: nextPath, - }); - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(contentType), - /* questionToken */ undefined, - /* type */ mediaType, + ? transformSchemaObject( + mediaTypeObject, + { + ...options, + path: nextPath, + }, + false, + contentIndent, + ) + : transformMediaTypeObject( + mediaTypeObject, + { + ...options, + path: nextPath, + }, + contentIndent, + ); + type.push( + propertySignature({ + /* name */ name: tsPropertyIndex(contentType), + /* type */ type: mediaType, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(mediaTypeObject, contentIndent), + indent: contentIndent, + }), ); - addJSDocComment(mediaTypeObject, property); - type.push(property); } - return ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex("content"), - /* questionToken */ undefined, - /* type */ ts.factory.createTypeLiteralNode( - type.length - ? type - : // add `"*/*": never` if no media types are defined - [ - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex("*/*"), - /* questionToken */ QUESTION_TOKEN, - /* type */ NEVER, - ), - ], - ), - ), - ]); + const contentMembers = type.length + ? type + : [ + // add `"*/*": never` if no media types are defined + propertySignature({ + /* name */ name: tsPropertyIndex("*/*"), + /* questionToken */ optional: true, + /* type */ type: NEVER, + indent: contentIndent, + }), + ]; + + return typeLiteral( + [ + propertySignature({ + /* name */ name: tsPropertyIndex("content"), + /* type */ type: typeLiteral(contentMembers, memberIndent), + /* modifiers */ readonly: options.ctx.immutable, + indent: memberIndent, + }), + ], + indent, + ); } diff --git a/packages/openapi-typescript/src/transform/response-object.ts b/packages/openapi-typescript/src/transform/response-object.ts index 83775cc9b..db437233a 100644 --- a/packages/openapi-typescript/src/transform/response-object.ts +++ b/packages/openapi-typescript/src/transform/response-object.ts @@ -1,12 +1,13 @@ -import ts from "typescript"; import { addJSDocComment, + INDENT, + indexSignature, NEVER, oapiRef, - QUESTION_TOKEN, - STRING, - tsModifiers, + propertySignature, + type TSNode, tsPropertyIndex, + typeLiteral, UNKNOWN, } from "../lib/ts.js"; import { createRef, getEntries } from "../lib/utils.js"; @@ -21,92 +22,98 @@ import transformMediaTypeObject from "./media-type-object.js"; export default function transformResponseObject( responseObject: ResponseObject, options: TransformNodeOptions, -): ts.TypeNode { - const type: ts.TypeElement[] = []; + indent = "", +): TSNode { + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; // headers - const headersObject: ts.TypeElement[] = []; + const headerIndent = `${memberIndent}${INDENT}`; + const headersObject: TSNode[] = []; if (responseObject.headers) { for (const [name, headerObject] of getEntries(responseObject.headers, options.ctx)) { - const optional = "$ref" in headerObject || headerObject.required ? undefined : QUESTION_TOKEN; + const optional = !("$ref" in headerObject) && !headerObject.required; const subType = "$ref" in headerObject - ? oapiRef(headerObject.$ref) - : transformHeaderObject(headerObject, { - ...options, - path: createRef([options.path, "headers", name]), - }); - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(name), - /* questionToken */ optional, - /* type */ subType, + ? oapiRef(headerObject.$ref, undefined, { indent: headerIndent }) + : transformHeaderObject( + headerObject, + { + ...options, + path: createRef([options.path, "headers", name]), + }, + headerIndent, + ); + headersObject.push( + propertySignature({ + /* name */ name: tsPropertyIndex(name), + /* type */ type: subType, + /* questionToken */ optional, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(headerObject, headerIndent), + indent: headerIndent, + }), ); - addJSDocComment(headerObject, property); - headersObject.push(property); } } // allow additional unknown headers headersObject.push( - ts.factory.createIndexSignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* parameters */ [ - ts.factory.createParameterDeclaration( - /* modifiers */ undefined, - /* dotDotDotToken */ undefined, - /* name */ ts.factory.createIdentifier("name"), - /* questionToken */ undefined, - /* type */ STRING, - ), - ], - /* type */ UNKNOWN, - ), + indexSignature({ + /* parameters */ keyName: "name", + /* type */ valueType: UNKNOWN, + /* modifiers */ readonly: options.ctx.immutable, + indent: headerIndent, + }), ); type.push( - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex("headers"), - /* questionToken */ undefined, - /* type */ ts.factory.createTypeLiteralNode(headersObject), - ), + propertySignature({ + /* name */ name: tsPropertyIndex("headers"), + /* type */ type: typeLiteral(headersObject, memberIndent), + indent: memberIndent, + }), ); // content - const contentObject: ts.TypeElement[] = []; + const contentIndent = `${memberIndent}${INDENT}`; + const contentObject: TSNode[] = []; if (responseObject.content) { for (const [contentType, mediaTypeObject] of getEntries(responseObject.content ?? {}, options.ctx)) { - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(contentType), - /* questionToken */ undefined, - /* type */ transformMediaTypeObject(mediaTypeObject, { - ...options, - path: createRef([options.path, "content", contentType]), + contentObject.push( + propertySignature({ + /* name */ name: tsPropertyIndex(contentType), + /* type */ type: transformMediaTypeObject( + mediaTypeObject, + { + ...options, + path: createRef([options.path, "content", contentType]), + }, + contentIndent, + ), + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(mediaTypeObject, contentIndent), + indent: contentIndent, }), ); - addJSDocComment(mediaTypeObject, property); - contentObject.push(property); } } if (contentObject.length) { type.push( - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex("content"), - /* questionToken */ undefined, - /* type */ ts.factory.createTypeLiteralNode(contentObject), - ), + propertySignature({ + /* name */ name: tsPropertyIndex("content"), + /* type */ type: typeLiteral(contentObject, memberIndent), + indent: memberIndent, + }), ); } else { type.push( - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex("content"), - /* questionToken */ QUESTION_TOKEN, - /* type */ NEVER, - ), + propertySignature({ + /* name */ name: tsPropertyIndex("content"), + /* questionToken */ optional: true, + /* type */ type: NEVER, + indent: memberIndent, + }), ); } - return ts.factory.createTypeLiteralNode(type); + return typeLiteral(type, indent); } diff --git a/packages/openapi-typescript/src/transform/responses-object.ts b/packages/openapi-typescript/src/transform/responses-object.ts index cc8bce397..307433d75 100644 --- a/packages/openapi-typescript/src/transform/responses-object.ts +++ b/packages/openapi-typescript/src/transform/responses-object.ts @@ -1,5 +1,13 @@ -import ts from "typescript"; -import { addJSDocComment, NEVER, oapiRef, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { + addJSDocComment, + INDENT, + NEVER, + oapiRef, + propertySignature, + type TSNode, + tsPropertyIndex, + typeLiteral, +} from "../lib/ts.js"; import { createRef, getEntries } from "../lib/utils.js"; import type { ResponsesObject, TransformNodeOptions } from "../types.js"; import transformResponseObject from "./response-object.js"; @@ -11,26 +19,33 @@ import transformResponseObject from "./response-object.js"; export default function transformResponsesObject( responsesObject: ResponsesObject, options: TransformNodeOptions, -): ts.TypeNode { - const type: ts.TypeElement[] = []; + indent = "", +): TSNode { + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; for (const [responseCode, responseObject] of getEntries(responsesObject, options.ctx)) { const responseType = "$ref" in responseObject - ? oapiRef(responseObject.$ref) - : transformResponseObject(responseObject, { - ...options, - path: createRef([options.path, "responses", responseCode]), - }); - const property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ readonly: options.ctx.immutable }), - /* name */ tsPropertyIndex(responseCode), - /* questionToken */ undefined, - /* type */ responseType, + ? oapiRef(responseObject.$ref, undefined, { indent: memberIndent }) + : transformResponseObject( + responseObject, + { + ...options, + path: createRef([options.path, "responses", responseCode]), + }, + memberIndent, + ); + type.push( + propertySignature({ + /* name */ name: tsPropertyIndex(responseCode), + /* type */ type: responseType, + /* modifiers */ readonly: options.ctx.immutable, + comment: addJSDocComment(responseObject, memberIndent), + indent: memberIndent, + }), ); - addJSDocComment(responseObject, property); - type.push(property); } - return type.length ? ts.factory.createTypeLiteralNode(type) : NEVER; + return type.length ? typeLiteral(type, indent) : NEVER; } diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index caab5e10f..a842c9465 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -1,31 +1,35 @@ import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; -import ts from "typescript"; import { addJSDocComment, BOOLEAN, + INDENT, + indexSignature, NEVER, NULL, NUMBER, oapiRef, - QUESTION_TOKEN, + propertySignature, STRING, + type TSNode, tsArrayLiteralExpression, tsEnum, tsIntersection, tsIsPrimitive, tsLiteral, - tsModifiers, tsNullable, tsOmit, + tsParenthesize, tsPropertyIndex, tsRecord, tsUnion, tsWithRequired, + tupleType, + typeLiteral, UNDEFINED, UNKNOWN, } from "../lib/ts.js"; import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js"; -import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; +import type { PropertySignatureLike, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; /** * Transform SchemaObject nodes (4.8.24) @@ -35,8 +39,9 @@ export default function transformSchemaObject( schemaObject: SchemaObject | ReferenceObject, options: TransformNodeOptions, fromAdditionalProperties = false, -): ts.TypeNode { - const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties); + indent = "", +): TSNode { + const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties, indent); if (typeof options.ctx.postTransform === "function") { const postTransformResult = options.ctx.postTransform(type, options); if (postTransformResult) { @@ -53,7 +58,8 @@ export function transformSchemaObjectWithComposition( schemaObject: SchemaObject | ReferenceObject, options: TransformNodeOptions, fromAdditionalProperties = false, -): ts.TypeNode { + indent = "", +): TSNode { /** * Unexpected types & edge cases */ @@ -77,14 +83,14 @@ export function transformSchemaObjectWithComposition( * ReferenceObject */ if ("$ref" in schemaObject) { - return oapiRef(schemaObject.$ref); + return oapiRef(schemaObject.$ref, undefined, { indent }); } /** * const (valid for any type) */ if (schemaObject.const !== null && schemaObject.const !== undefined) { - return tsLiteral(schemaObject.const); + return tsLiteral(schemaObject.const, indent); } /** @@ -124,17 +130,17 @@ export function transformSchemaObjectWithComposition( export: true, // readonly: TS enum do not support the readonly modifier }); - if (!options.ctx.injectFooter.includes(enumType)) { - options.ctx.injectFooter.push(enumType); + if (!options.ctx.injectFooter.includes(enumType.declaration)) { + options.ctx.injectFooter.push(enumType.declaration); } - const ref = ts.factory.createTypeReferenceNode(enumType.name); + const ref = enumType.name; - const finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref; + const finalType: TSNode = hasNull ? tsUnion([ref, NULL]) : ref; return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject); } - const enumType = schemaObject.enum.map(tsLiteral); + const enumType = schemaObject.enum.map((v) => tsLiteral(v, indent)); if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { enumType.push(NULL); } @@ -189,10 +195,7 @@ export function transformSchemaObjectWithComposition( enumValuesVariableName, // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type fromAdditionalProperties - ? ts.factory.createIndexedAccessTypeNode( - oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), - ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")), - ) + ? `${oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties })}[string]` : oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), schemaObject.enum as (string | number)[], { @@ -215,14 +218,19 @@ export function transformSchemaObjectWithComposition( /** Collect oneOf/anyOf */ function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") { - const output: ts.TypeNode[] = []; + const output: TSNode[] = []; for (const [index, item] of items.entries()) { output.push( - transformSchemaObject(item, { - ...options, - // include index in path so generated names from nested enums/enumValues are unique - path: createRef([options.path, unionKey, String(index)]), - }), + transformSchemaObject( + item, + { + ...options, + // include index in path so generated names from nested enums/enumValues are unique + path: createRef([options.path, unionKey, String(index)]), + }, + false, + indent, + ), ); } @@ -230,14 +238,14 @@ export function transformSchemaObjectWithComposition( } /** Collect allOf with Omit<> for discriminators */ - function collectAllOfCompositions(items: (SchemaObject | ReferenceObject)[], required?: string[]): ts.TypeNode[] { - const output: ts.TypeNode[] = []; + function collectAllOfCompositions(items: (SchemaObject | ReferenceObject)[], required?: string[]): TSNode[] { + const output: TSNode[] = []; for (const item of items) { - let itemType: ts.TypeNode; + let itemType: TSNode; // if this is a $ref, use WithRequired if parent specifies required properties // (but only for valid keys) if ("$ref" in item) { - itemType = transformSchemaObject(item, options); + itemType = transformSchemaObject(item, options, false, indent); const resolved = options.ctx.resolve(item.$ref); @@ -262,7 +270,7 @@ export function transformSchemaObjectWithComposition( if (typeof item === "object" && Array.isArray(item.required)) { itemRequired.push(...item.required); } - itemType = transformSchemaObject({ ...item, required: itemRequired }, options); + itemType = transformSchemaObject({ ...item, required: itemRequired }, options, false, indent); } const discriminator = @@ -277,13 +285,13 @@ export function transformSchemaObjectWithComposition( } // compile final type - let finalType: ts.TypeNode | undefined; + let finalType: TSNode | undefined; // core + allOf: intersect - const coreObjectType = transformSchemaObjectCore(schemaObject, options); + const coreObjectType = transformSchemaObjectCore(schemaObject, options, indent); const allOfType = collectAllOfCompositions(schemaObject.allOf ?? [], schemaObject.required); if (coreObjectType || allOfType.length) { - const allOf: ts.TypeNode | undefined = allOfType.length ? tsIntersection(allOfType) : undefined; + const allOf: TSNode | undefined = allOfType.length ? tsIntersection(allOfType) : undefined; finalType = tsIntersection([...(coreObjectType ? [coreObjectType] : []), ...(allOf ? [allOf] : [])]); } // anyOf: union @@ -355,23 +363,30 @@ function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: Sc return true; } +/** Is a generated type expression already an array or tuple type? */ +function isArrayLikeType(expression: TSNode): boolean { + return expression.endsWith("[]") || (expression.startsWith("[") && expression.endsWith("]")); +} + /** * Handle SchemaObject minus composition (anyOf/allOf/oneOf) */ -function transformSchemaObjectCore(schemaObject: SchemaObject, options: TransformNodeOptions): ts.TypeNode | undefined { +function transformSchemaObjectCore( + schemaObject: SchemaObject, + options: TransformNodeOptions, + indent = "", +): TSNode | undefined { if ("type" in schemaObject && schemaObject.type) { if (typeof options.ctx.transform === "function") { const result = options.ctx.transform(schemaObject, options); - if (result && typeof result === "object") { - if ("schema" in result) { + if (result) { + if (typeof result === "object" && "schema" in result) { if (result.questionToken) { - return ts.factory.createUnionTypeNode([result.schema, UNDEFINED]); - } else { - return result.schema; + return tsUnion([result.schema, UNDEFINED]); } - } else { - return result; + return result.schema; } + return result as TSNode; } } @@ -395,22 +410,27 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor // type: array (with support for tuples) if (schemaObject.type === "array") { - // default to `unknown[]` - let itemType: ts.TypeNode = UNKNOWN; - // tuple type - if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) { - const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]); - itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options))); - } - // standard array type - else if (schemaObject.items) { - if (hasKey(schemaObject.items, "type") && schemaObject.items.type === "array") { - itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options)); - } else { - itemType = transformSchemaObject(schemaObject.items, options); + /** Build the array element type at `elementIndent` */ + const buildItemType = (elementIndent: string): TSNode => { + // tuple type + if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) { + const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]); + return tupleType( + prefixItems.map((item) => transformSchemaObject(item, options, false, `${elementIndent}${INDENT}`)), + elementIndent, + ); } - } + // standard array type + if (schemaObject.items) { + if (hasKey(schemaObject.items, "type") && schemaObject.items.type === "array") { + return `${tsParenthesize(transformSchemaObject(schemaObject.items, options, false, elementIndent))}[]`; + } + return transformSchemaObject(schemaObject.items, options, false, elementIndent); + } + return UNKNOWN; + }; + const isTupleShape = Boolean(schemaObject.prefixItems || Array.isArray(schemaObject.items)); const min: number = typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 ? schemaObject.minItems : 0; const max: number | undefined = @@ -423,50 +443,48 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor (min !== 0 || max !== undefined) && estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice ) { + // tuple elements live one level deeper than the tuple itself + const elementIndent = `${indent}${INDENT}`; + const itemType = buildItemType(elementIndent); if (min === max) { - const elements: ts.TypeNode[] = []; + const elements: TSNode[] = []; for (let i = 0; i < min; i++) { elements.push(itemType); } - return tsUnion([ts.factory.createTupleTypeNode(elements)]); - } else if ((schemaObject.maxItems as number) > 0) { + return tsUnion([tupleType(elements, indent)]); + } + if ((schemaObject.maxItems as number) > 0) { // if maxItems is set, then return a union of all permutations of possible tuple types - const members: ts.TypeNode[] = []; + const members: TSNode[] = []; // populate 1 short of min … for (let i = 0; i <= (max ?? 0) - min; i++) { - const elements: ts.TypeNode[] = []; + const elements: TSNode[] = []; for (let j = min; j < i + min; j++) { elements.push(itemType); } - members.push(ts.factory.createTupleTypeNode(elements)); + members.push(tupleType(elements, indent)); } return tsUnion(members); } // if maxItems not set, then return a simple tuple type the length of `min` - else { - const elements: ts.TypeNode[] = []; - for (let i = 0; i < min; i++) { - elements.push(itemType); - } - elements.push(ts.factory.createRestTypeNode(ts.factory.createArrayTypeNode(itemType))); - return ts.factory.createTupleTypeNode(elements); + const elements: TSNode[] = []; + for (let i = 0; i < min; i++) { + elements.push(itemType); } + elements.push(`...${tsParenthesize(itemType)}[]`); + return tupleType(elements, indent); } - const finalType = - ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType) - ? itemType - : ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already + const itemType = buildItemType(indent); + const finalType = isTupleShape || isArrayLikeType(itemType) ? itemType : `${tsParenthesize(itemType)}[]`; - return options.ctx.immutable - ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType) - : finalType; + return options.ctx.immutable ? `readonly ${tsParenthesize(finalType)}` : finalType; } // polymorphic, or 3.1 nullable if (Array.isArray(schemaObject.type) && !Array.isArray(schemaObject)) { // skip any primitive types that appear in oneOf as well - const uniqueTypes: ts.TypeNode[] = []; + const uniqueTypes: TSNode[] = []; if (Array.isArray(schemaObject.oneOf)) { for (const t of schemaObject.type) { if ( @@ -481,6 +499,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor : transformSchemaObject( { ...schemaObject, type: t, oneOf: undefined } as SchemaObject, // don’t stack oneOf transforms options, + false, + indent, ), ); } @@ -489,7 +509,9 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (t === "null" || t === null) { uniqueTypes.push(NULL); } else { - uniqueTypes.push(transformSchemaObject({ ...schemaObject, type: t } as SchemaObject, options)); + uniqueTypes.push( + transformSchemaObject({ ...schemaObject, type: t } as SchemaObject, options, false, indent), + ); } } } @@ -498,7 +520,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } // type: object - const coreObjectType: ts.TypeElement[] = []; + const memberIndent = `${indent}${INDENT}`; + const coreObjectType: TSNode[] = []; // discriminators: explicit mapping on schema object for (const k of ["allOf", "anyOf"] as const) { @@ -519,6 +542,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor createDiscriminatorProperty(discriminator, { path: options.path ?? "", readonly: options.ctx.immutable, + indent: memberIndent, }), ); break; @@ -559,105 +583,125 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor continue; } } - let optional = - schemaObject.required?.includes(k) || - (schemaObject.required === undefined && options.ctx.propertiesRequiredByDefault) || - (hasDefault && - options.ctx.defaultNonNullable && - !options.path?.includes("parameters") && - !options.path?.includes("requestBody") && - !options.path?.includes("requestBodies")) // can’t be required, even with defaults - ? undefined - : QUESTION_TOKEN; + let optional = !( + ( + schemaObject.required?.includes(k) || + (schemaObject.required === undefined && options.ctx.propertiesRequiredByDefault) || + (hasDefault && + options.ctx.defaultNonNullable && + !options.path?.includes("parameters") && + !options.path?.includes("requestBody") && + !options.path?.includes("requestBodies")) + ) // can’t be required, even with defaults + ); let type = $ref - ? oapiRef($ref) - : transformSchemaObject(v, { - ...options, - path: createRef([options.path, k]), - }); + ? oapiRef($ref, undefined, { indent: memberIndent }) + : transformSchemaObject( + v, + { + ...options, + path: createRef([options.path, k]), + }, + false, + memberIndent, + ); if (typeof options.ctx.transform === "function") { const result = options.ctx.transform(v as SchemaObject, options); - if (result && typeof result === "object") { - if ("schema" in result) { + if (result) { + if (typeof result === "object" && "schema" in result) { type = result.schema; - optional = result.questionToken ? QUESTION_TOKEN : optional; + optional = result.questionToken ? true : optional; } else { - type = result; + type = result as TSNode; } } } type = wrapWithReadWriteMarker(type, !!readOnly, !!writeOnly, options.ctx); - let property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ - readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && readOnly), - }), - /* name */ tsPropertyIndex(k), - /* questionToken */ optional, - /* type */ type, - ); + const propertyLike: PropertySignatureLike = { + name: tsPropertyIndex(k), + optional, + type, + indent: memberIndent, + }; // Apply transformProperty hook if available + let finalProperty = propertyLike; if (typeof options.ctx.transformProperty === "function") { - const result = options.ctx.transformProperty(property, v as SchemaObject, { + const result = options.ctx.transformProperty(propertyLike, v as SchemaObject, { ...options, path: createRef([options.path, k]), }); if (result) { - property = result; + finalProperty = result; } } - addJSDocComment(v, property); - coreObjectType.push(property); + coreObjectType.push( + propertySignature({ + name: finalProperty.name, + type: finalProperty.type, + optional: finalProperty.optional, + /* modifiers */ readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && readOnly), + comment: `${finalProperty.comment ?? ""}${addJSDocComment(v, memberIndent)}`, + indent: memberIndent, + }), + ); } } // $defs if ("$defs" in schemaObject && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { - const defKeys: ts.TypeElement[] = []; + const defsIndent = `${memberIndent}${INDENT}`; + const defKeys: TSNode[] = []; for (const [k, v] of Object.entries(schemaObject.$defs)) { const defReadOnly = "readOnly" in v && !!v.readOnly; const defWriteOnly = "writeOnly" in v && !!v.writeOnly; const defType = wrapWithReadWriteMarker( - transformSchemaObject(v, { ...options, path: createRef([options.path, "$defs", k]) }), + transformSchemaObject(v, { ...options, path: createRef([options.path, "$defs", k]) }, false, defsIndent), defReadOnly, defWriteOnly, options.ctx, ); - let property = ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ - readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && defReadOnly), - }), - /* name */ tsPropertyIndex(k), - /* questionToken */ undefined, - /* type */ defType, - ); + const propertyLike: PropertySignatureLike = { + name: tsPropertyIndex(k), + optional: false, + type: defType, + indent: defsIndent, + }; // Apply transformProperty hook if available + let finalProperty = propertyLike; if (typeof options.ctx.transformProperty === "function") { - const result = options.ctx.transformProperty(property, v as SchemaObject, { + const result = options.ctx.transformProperty(propertyLike, v as SchemaObject, { ...options, path: createRef([options.path, "$defs", k]), }); if (result) { - property = result; + finalProperty = result; } } - addJSDocComment(v, property); - defKeys.push(property); + defKeys.push( + propertySignature({ + name: finalProperty.name, + type: finalProperty.type, + optional: finalProperty.optional, + /* modifiers */ readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && defReadOnly), + comment: `${finalProperty.comment ?? ""}${addJSDocComment(v, defsIndent)}`, + indent: defsIndent, + }), + ); } coreObjectType.push( - ts.factory.createPropertySignature( - /* modifiers */ undefined, - /* name */ tsPropertyIndex("$defs"), - /* questionToken */ undefined, - /* type */ ts.factory.createTypeLiteralNode(defKeys), - ), + propertySignature({ + /* name */ name: tsPropertyIndex("$defs"), + /* type */ type: typeLiteral(defKeys, memberIndent), + indent: memberIndent, + }), ); } @@ -673,7 +717,9 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor typeof patternProperties === "object" && patternProperties !== null && Object.keys(patternProperties).length > 0; const stringIndexTypes = []; if (hasExplicitAdditionalProperties) { - stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true)); + stringIndexTypes.push( + transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true, memberIndent), + ); } if (hasImplicitAdditionalProperties || (!schemaObject.additionalProperties && options.ctx.additionalProperties)) { stringIndexTypes.push(UNKNOWN); @@ -683,39 +729,33 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor patternProperties as Record, options.ctx, )) { - stringIndexTypes.push(transformSchemaObject(v, options)); + stringIndexTypes.push(transformSchemaObject(v, options, false, memberIndent)); } } if (stringIndexTypes.length === 0) { - return coreObjectType.length ? ts.factory.createTypeLiteralNode(coreObjectType) : undefined; + return coreObjectType.length ? typeLiteral(coreObjectType, indent) : undefined; } const stringIndexType = tsUnion(stringIndexTypes); return tsIntersection([ - ...(coreObjectType.length ? [ts.factory.createTypeLiteralNode(coreObjectType)] : []), - ts.factory.createTypeLiteralNode([ - ts.factory.createIndexSignature( - /* modifiers */ tsModifiers({ - readonly: options.ctx.immutable, + ...(coreObjectType.length ? [typeLiteral(coreObjectType, indent)] : []), + typeLiteral( + [ + indexSignature({ + /* modifiers */ readonly: options.ctx.immutable, + /* parameters */ keyName: "key", + /* type */ valueType: stringIndexType, + indent: memberIndent, }), - /* parameters */ [ - ts.factory.createParameterDeclaration( - /* modifiers */ undefined, - /* dotDotDotToken */ undefined, - /* name */ ts.factory.createIdentifier("key"), - /* questionToken */ undefined, - /* type */ STRING, - ), - ], - /* type */ stringIndexType, - ), - ]), + ], + indent, + ), ]); } - return coreObjectType.length ? ts.factory.createTypeLiteralNode(coreObjectType) : undefined; + return coreObjectType.length ? typeLiteral(coreObjectType, indent) : undefined; } /** @@ -730,12 +770,12 @@ function hasKey(possibleObject: unknown, key: K): possibleObje function applyAdditionalPropertiesToEnum( hasAdditionalProperties: boolean, - unionType: ts.TypeNode, + unionType: TSNode, schemaObject: SchemaObject, -) { +): TSNode { // If additionalProperties is true, add (string & {}) to the union if (hasAdditionalProperties && schemaObject.type === "string") { - const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]); + const stringAndEmptyObject = tsIntersection([STRING, "{}"]); return tsUnion([unionType, stringAndEmptyObject]); } return unionType; @@ -743,19 +783,19 @@ function applyAdditionalPropertiesToEnum( /** Wrap type with $Read or $Write marker when readWriteMarkers flag is enabled */ function wrapWithReadWriteMarker( - type: ts.TypeNode, + type: TSNode, readOnly: boolean, writeOnly: boolean, ctx: { readWriteMarkers: boolean }, -): ts.TypeNode { +): TSNode { if (!ctx.readWriteMarkers || (readOnly && writeOnly)) { return type; } if (readOnly) { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("$Read"), [type]); + return `$Read<${type}>`; } if (writeOnly) { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("$Write"), [type]); + return `$Write<${type}>`; } return type; } diff --git a/packages/openapi-typescript/src/transform/webhooks-object.ts b/packages/openapi-typescript/src/transform/webhooks-object.ts index 28c3df644..9e5ebc473 100644 --- a/packages/openapi-typescript/src/transform/webhooks-object.ts +++ b/packages/openapi-typescript/src/transform/webhooks-object.ts @@ -1,27 +1,33 @@ -import ts from "typescript"; -import { tsModifiers, tsPropertyIndex } from "../lib/ts.js"; +import { INDENT, propertySignature, type TSNode, tsPropertyIndex, typeLiteral } from "../lib/ts.js"; import { createRef, getEntries } from "../lib/utils.js"; import type { GlobalContext, WebhooksObject } from "../types.js"; import transformPathItemObject from "./path-item-object.js"; -export default function transformWebhooksObject(webhooksObject: WebhooksObject, options: GlobalContext): ts.TypeNode { - const type: ts.TypeElement[] = []; +export default function transformWebhooksObject( + webhooksObject: WebhooksObject, + options: GlobalContext, + indent = "", +): TSNode { + const memberIndent = `${indent}${INDENT}`; + const type: TSNode[] = []; for (const [name, pathItemObject] of getEntries(webhooksObject, options)) { type.push( - ts.factory.createPropertySignature( - /* modifiers */ tsModifiers({ - readonly: options.immutable, - }), - /* name */ tsPropertyIndex(name), - /* questionToken */ undefined, - /* type */ transformPathItemObject(pathItemObject, { - path: createRef(["webhooks", name]), - ctx: options, - }), - ), + propertySignature({ + /* name */ name: tsPropertyIndex(name), + /* type */ type: transformPathItemObject( + pathItemObject, + { + path: createRef(["webhooks", name]), + ctx: options, + }, + memberIndent, + ), + /* modifiers */ readonly: options.immutable, + indent: memberIndent, + }), ); } - return ts.factory.createTypeLiteralNode(type); + return typeLiteral(type, indent); } diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index d19185cc6..b42673030 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -1,6 +1,6 @@ import type { PathLike } from "node:fs"; import type { Config as RedoclyConfig } from "@redocly/openapi-core"; -import type ts from "typescript"; +import type { FooterDeclaration, TSNode } from "./lib/ts.js"; // Many types allow for true “any” for inheritance to work @@ -458,10 +458,30 @@ export type SchemaObject = { ); export interface TransformObject { - schema: ts.TypeNode; + schema: TSNode; questionToken: boolean; } +/** + * Structured view of a generated property signature, handed to the + * `transformProperty` hook so callers can patch it without dealing with + * indentation or quoting. + */ +export interface PropertySignatureLike { + /** Rendered property name (already quoted/sanitized when necessary) */ + name: string; + optional: boolean; + type: TSNode; + /** + * JSDoc block prepended to the property. It must carry its own indentation and + * trailing newline — build it with `tsComment()` from `openapi-typescript`. + * An existing comment supplied by the schema itself is appended after it. + */ + comment?: string; + /** Indentation of the generated property line (useful for rendering comments) */ + indent: string; +} + export interface StringSubtype { type: "string" | ["string", "null"]; enum?: (string | ReferenceObject)[]; @@ -638,15 +658,15 @@ export interface OpenAPITSOptions { /** Exclude deprecated fields from types? (default: false) */ excludeDeprecated?: boolean; /** Manually transform certain Schema Objects with a custom TypeScript type */ - transform?: (schemaObject: SchemaObject, options: TransformNodeOptions) => ts.TypeNode | TransformObject | undefined; + transform?: (schemaObject: SchemaObject, options: TransformNodeOptions) => TSNode | TransformObject | undefined; /** Modify TypeScript types built from Schema Objects */ - postTransform?: (type: ts.TypeNode, options: TransformNodeOptions) => ts.TypeNode | undefined; + postTransform?: (type: TSNode, options: TransformNodeOptions) => TSNode | undefined; /** Modify property signatures for Schema Object properties */ transformProperty?: ( - property: ts.PropertySignature, + property: PropertySignatureLike, schemaObject: SchemaObject, options: TransformNodeOptions, - ) => ts.PropertySignature | undefined; + ) => PropertySignatureLike | undefined; /** Add readonly properties and readonly arrays? (default: false) */ immutable?: boolean; /** (optional) Should logging be suppressed? (necessary for STDOUT) */ @@ -707,7 +727,7 @@ export interface GlobalContext { excludeDeprecated: boolean; exportType: boolean; immutable: boolean; - injectFooter: ts.Node[]; + injectFooter: FooterDeclaration[]; pathParamsAsTypes: boolean; postTransform: OpenAPITSOptions["postTransform"]; propertiesRequiredByDefault: boolean; diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index 79e5b4571..9e18a9ebf 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -1,41 +1,59 @@ -import ts from "typescript"; import { addJSDocComment, - astToString, BOOLEAN, + INDENT, NULL, NUMBER, oapiRef, + propertySignature, STRING, tsArrayLiteralExpression, tsEnum, + tsEnumMember, + tsIntersection, tsIsPrimitive, tsLiteral, + tsNullable, + tsParenthesize, tsPropertyIndex, tsUnion, + typeLiteral, } from "../../src/lib/ts.js"; +/** Build the `{ comment: T }` literal the comment tests assert against */ +function commentLiteral(schemaObject: any, type: string, commentIndent = INDENT) { + return typeLiteral( + [ + propertySignature({ + name: "comment", + type, + comment: addJSDocComment(schemaObject, commentIndent), + indent: INDENT, + }), + ], + "", + ); +} + describe("addJSDocComment", () => { test("single-line comment", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment({ description: "Single-line comment" }, property); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect(commentLiteral({ description: "Single-line comment" }, BOOLEAN)).toBe(`{ /** @description Single-line comment */ comment: boolean; }`); }); test("multi-line comment", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment( - { - summary: "This is the summary", - description: "Multi-line comment\nLine 2", - deprecated: true, - }, - property, - ); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect( + commentLiteral( + { + summary: "This is the summary", + description: "Multi-line comment\nLine 2", + deprecated: true, + }, + BOOLEAN, + ), + ).toBe(`{ /** * This is the summary * @deprecated @@ -47,37 +65,21 @@ describe("addJSDocComment", () => { }); test("escapes internal comments", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment({ title: "This is a comment with `/* an example comment */` within" }, property); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect(commentLiteral({ title: "This is a comment with `/* an example comment */` within" }, BOOLEAN)).toBe(`{ /** This is a comment with \`/* an example comment *\\/\` within */ comment: boolean; }`); }); test("single example", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment( - { - example: "an-example", - }, - property, - ); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect(commentLiteral({ example: "an-example" }, BOOLEAN)).toBe(`{ /** @example an-example */ comment: boolean; }`); }); test("array of examples", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment( - { - examples: ["an-example", "another-example"], - }, - property, - ); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect(commentLiteral({ examples: ["an-example", "another-example"] }, BOOLEAN)).toBe(`{ /** * @example an-example * @example another-example @@ -87,15 +89,7 @@ describe("addJSDocComment", () => { }); test("single example and array of examples", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment( - { - example: "old-example", - examples: ["an-example", "another-example"], - }, - property, - ); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect(commentLiteral({ example: "old-example", examples: ["an-example", "another-example"] }, BOOLEAN)).toBe(`{ /** * @example old-example * @example an-example @@ -106,23 +100,23 @@ describe("addJSDocComment", () => { }); test("complex examples", () => { - const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN); - addJSDocComment( - { - examples: [ - { - foo: "bar", - results: [1, true, "abc"], - }, - { - foo: "bat", - results: [5, false, "def"], - }, - ], - }, - property, - ); - expect(astToString(ts.factory.createTypeLiteralNode([property])).trim()).toBe(`{ + expect( + commentLiteral( + { + examples: [ + { + foo: "bar", + results: [1, true, "abc"], + }, + { + foo: "bat", + results: [5, false, "def"], + }, + ], + }, + BOOLEAN, + ), + ).toBe(`{ /** * @example { * "foo": "bar", @@ -148,39 +142,35 @@ describe("addJSDocComment", () => { describe("oapiRef", () => { test("single part", () => { - expect(astToString(oapiRef("#/components")).trim()).toBe("components"); + expect(oapiRef("#/components")).toBe("components"); }); test("multiple parts", () => { - expect(astToString(oapiRef("#/components/schemas/User")).trim()).toBe(`components["schemas"]["User"]`); + expect(oapiRef("#/components/schemas/User")).toBe(`components["schemas"]["User"]`); }); test("`properties` of component schema `properties`", () => { - expect(astToString(oapiRef("#/components/schemas/User/properties/username")).trim()).toBe( - `components["schemas"]["User"]["username"]`, - ); + expect(oapiRef("#/components/schemas/User/properties/username")).toBe(`components["schemas"]["User"]["username"]`); }); test("component schema named `properties`", () => { - expect(astToString(oapiRef("#/components/schemas/properties")).trim()).toBe(`components["schemas"]["properties"]`); + expect(oapiRef("#/components/schemas/properties")).toBe(`components["schemas"]["properties"]`); }); test("reference into paths parameters", () => { expect( - astToString( - oapiRef("#/paths/~1endpoint/get/parameters/0", { - in: "query", - name: "boop", - required: true, - }), - ).trim(), + oapiRef("#/paths/~1endpoint/get/parameters/0", { + in: "query", + name: "boop", + required: true, + }), ).toBe('paths["/endpoint"]["get"]["parameters"]["query"]["boop"]'); }); }); describe("tsEnum", () => { test("string members", () => { - expect(astToString(tsEnum("-my-color-", ["green", "red", "blue"])).trim()).toBe(`enum MyColor { + expect(tsEnum("-my-color-", ["green", "red", "blue"]).declaration).toBe(`enum MyColor { green = "green", red = "red", blue = "blue" @@ -189,11 +179,9 @@ describe("tsEnum", () => { test("with setting: export", () => { expect( - astToString( - tsEnum("-my-color-", ["green", "red", "blue"], undefined, { - export: true, - }), - ).trim(), + tsEnum("-my-color-", ["green", "red", "blue"], undefined, { + export: true, + }).declaration, ).toBe(`export enum MyColor { green = "green", red = "red", @@ -203,7 +191,7 @@ describe("tsEnum", () => { test("name from path", () => { expect( - astToString(tsEnum("#/paths/url/get/parameters/query/status", ["active", "inactive"])).trim(), + tsEnum("#/paths/url/get/parameters/query/status", ["active", "inactive"]).declaration, ).toBe(`enum PathsUrlGetParametersQueryStatus { active = "active", inactive = "inactive" @@ -211,7 +199,7 @@ describe("tsEnum", () => { }); test("string members with numeric prefix", () => { - expect(astToString(tsEnum("/my/enum/", ["0a", "1b", "2c"])).trim()).toBe(`enum MyEnum { + expect(tsEnum("/my/enum/", ["0a", "1b", "2c"]).declaration).toBe(`enum MyEnum { Value0a = "0a", Value1b = "1b", Value2c = "2c" @@ -219,7 +207,7 @@ describe("tsEnum", () => { }); test("number members", () => { - expect(astToString(tsEnum(".Error.code.", [100, 101, 102, -100])).trim()).toBe(`enum ErrorCode { + expect(tsEnum(".Error.code.", [100, 101, 102, -100]).declaration).toBe(`enum ErrorCode { Value100 = 100, Value101 = 101, Value102 = 102, @@ -229,13 +217,11 @@ describe("tsEnum", () => { test("number members with x-enum-descriptions", () => { expect( - astToString( - tsEnum( - ".Error.code.", - [100, 101, 102], - [{ description: "Code 100" }, { description: "Code 101" }, { description: "Code 102" }], - ), - ).trim(), + tsEnum( + ".Error.code.", + [100, 101, 102], + [{ description: "Code 100" }, { description: "Code 101" }, { description: "Code 102" }], + ).declaration, ).toBe(`enum ErrorCode { // Code 100 Value100 = 100, @@ -248,13 +234,11 @@ describe("tsEnum", () => { test("x-enum-varnames", () => { expect( - astToString( - tsEnum( - ".Error.code.", - [100, 101, 102], - [{ name: "Unauthorized" }, { name: "NotFound" }, { name: "PermissionDenied" }], - ), - ).trim(), + tsEnum( + ".Error.code.", + [100, 101, 102], + [{ name: "Unauthorized" }, { name: "NotFound" }, { name: "PermissionDenied" }], + ).declaration, ).toBe(`enum ErrorCode { Unauthorized = 100, NotFound = 101, @@ -264,7 +248,7 @@ describe("tsEnum", () => { test("x-enum-varnames with numeric prefix", () => { expect( - astToString(tsEnum(".Error.code.", [100, 101, 102], [{ name: "0a" }, { name: "1b" }, { name: "2c" }])).trim(), + tsEnum(".Error.code.", [100, 101, 102], [{ name: "0a" }, { name: "1b" }, { name: "2c" }]).declaration, ).toBe(`enum ErrorCode { Value0a = 100, Value1b = 101, @@ -274,17 +258,15 @@ describe("tsEnum", () => { test("partial x-enum-varnames and x-enum-descriptions", () => { expect( - astToString( - tsEnum( - ".Error.code.", - [100, 101, 102], - [ - { name: "Unauthorized", description: "User is unauthorized" }, - { name: "NotFound", description: "" }, - { name: "Value102", description: null }, - ], - ), - ).trim(), + tsEnum( + ".Error.code.", + [100, 101, 102], + [ + { name: "Unauthorized", description: "User is unauthorized" }, + { name: "NotFound", description: "" }, + { name: "Value102", description: null }, + ], + ).declaration, ).toBe(`enum ErrorCode { // User is unauthorized Unauthorized = 100, @@ -295,20 +277,18 @@ describe("tsEnum", () => { test("x-enum-descriptions with x-enum-varnames", () => { expect( - astToString( - tsEnum( - ".Error.code.", - [100, 101, 102], - [ - { name: "Unauthorized", description: "User is unauthorized" }, - { name: "NotFound", description: "Item not found" }, - { - name: "PermissionDenied", - description: "User doesn't have permissions", - }, - ], - ), - ).trim(), + tsEnum( + ".Error.code.", + [100, 101, 102], + [ + { name: "Unauthorized", description: "User is unauthorized" }, + { name: "NotFound", description: "Item not found" }, + { + name: "PermissionDenied", + description: "User doesn't have permissions", + }, + ], + ).declaration, ).toBe(`enum ErrorCode { // User is unauthorized Unauthorized = 100, @@ -319,8 +299,19 @@ describe("tsEnum", () => { }`); }); + test("multi-line x-enum-descriptions stay on one comment line", () => { + // a `//` comment ends at the first line break, so line breaks are flattened + // rather than leaking a bare token into the enum body + expect(tsEnum("E", ["a"], [{ description: "line1\nline2" }]).declaration).toBe( + `enum E { + // line1 line2 + a = "a" +}`, + ); + }); + test("replace special character", () => { - expect(astToString(tsEnum("FOO_ENUM", ["Etc/GMT+0", "Etc/GMT+1", "Etc/GMT-1"])).trim()).toBe(`enum FOO_ENUM { + expect(tsEnum("FOO_ENUM", ["Etc/GMT+0", "Etc/GMT+1", "Etc/GMT-1"]).declaration).toBe(`enum FOO_ENUM { Etc_GMTPlus0 = "Etc/GMT+0", Etc_GMTPlus1 = "Etc/GMT+1", Etc_GMT_1 = "Etc/GMT-1" @@ -331,82 +322,68 @@ describe("tsEnum", () => { describe("tsArrayLiteralExpression", () => { test("string members", () => { expect( - astToString( - tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"]), - ).trim(), + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"]), ).toBe(`const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`); }); test("with setting: export", () => { expect( - astToString( - tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { - export: true, - }), - ).trim(), + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { + export: true, + }), ).toBe(`export const myColorValues: components["schemas"]["Color"][] = ["green", "red", "blue"];`); }); test("with setting: readonly", () => { expect( - astToString( - tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { - readonly: true, - }), - ).trim(), + tsArrayLiteralExpression("-my-color-Values", oapiRef("#/components/schemas/Color"), ["green", "red", "blue"], { + readonly: true, + }), ).toBe(`const myColorValues: ReadonlyArray = ["green", "red", "blue"];`); }); test("name from path", () => { expect( - astToString( - tsArrayLiteralExpression( - "#/paths/url/get/parameters/query/status/Values", - oapiRef("#/components/schemas/Status"), - ["active", "inactive"], - ), - ).trim(), + tsArrayLiteralExpression( + "#/paths/url/get/parameters/query/status/Values", + oapiRef("#/components/schemas/Status"), + ["active", "inactive"], + ), ).toBe(`const pathsUrlGetParametersQueryStatusValues: components["schemas"]["Status"][] = ["active", "inactive"];`); }); test("number members", () => { expect( - astToString( - tsArrayLiteralExpression( - ".Error.code.Values", - oapiRef("#/components/schemas/ErrorCode"), - [100, 101, 102, -100], - ), - ).trim(), + tsArrayLiteralExpression(".Error.code.Values", oapiRef("#/components/schemas/ErrorCode"), [100, 101, 102, -100]), ).toBe(`const errorCodeValues: components["schemas"]["ErrorCode"][] = [100, 101, 102, -100];`); }); }); describe("tsPropertyIndex", () => { test("numbers -> number literals", () => { - expect(astToString(tsPropertyIndex(200)).trim()).toBe("200"); - expect(astToString(tsPropertyIndex(200.5)).trim()).toBe("200.5"); - expect(astToString(tsPropertyIndex(Number.POSITIVE_INFINITY)).trim()).toBe("Infinity"); - expect(astToString(tsPropertyIndex(Number.NaN)).trim()).toBe("NaN"); - expect(astToString(tsPropertyIndex(10e3)).trim()).toBe("10000"); + expect(tsPropertyIndex(200)).toBe("200"); + expect(tsPropertyIndex(200.5)).toBe("200.5"); + expect(tsPropertyIndex(Number.POSITIVE_INFINITY)).toBe("Infinity"); + expect(tsPropertyIndex(Number.NaN)).toBe("NaN"); + expect(tsPropertyIndex(10e3)).toBe("10000"); }); test("valid strings -> identifiers", () => { - expect(astToString(tsPropertyIndex("identifier")).trim()).toBe("identifier"); - expect(astToString(tsPropertyIndex("snake_case")).trim()).toBe("snake_case"); - expect(astToString(tsPropertyIndex(200)).trim()).toBe("200"); - expect(astToString(tsPropertyIndex("$id")).trim()).toBe("$id"); - expect(astToString(tsPropertyIndex("10e3")).trim()).toBe(`"10e3"`); + expect(tsPropertyIndex("identifier")).toBe("identifier"); + expect(tsPropertyIndex("snake_case")).toBe("snake_case"); + expect(tsPropertyIndex(200)).toBe("200"); + expect(tsPropertyIndex("$id")).toBe("$id"); + expect(tsPropertyIndex("10e3")).toBe(`"10e3"`); }); test("invalid strings -> string literals", () => { - expect(astToString(tsPropertyIndex("kebab-case")).trim()).toBe(`"kebab-case"`); - expect(astToString(tsPropertyIndex("application/json")).trim()).toBe(`"application/json"`); - expect(astToString(tsPropertyIndex("0invalid")).trim()).toBe(`"0invalid"`); - expect(astToString(tsPropertyIndex("inv@lid")).trim()).toBe(`"inv@lid"`); - expect(astToString(tsPropertyIndex("in.valid")).trim()).toBe(`"in.valid"`); - expect(astToString(tsPropertyIndex(-1)).trim()).toBe(`"-1"`); - expect(astToString(tsPropertyIndex("-1")).trim()).toBe(`"-1"`); + expect(tsPropertyIndex("kebab-case")).toBe(`"kebab-case"`); + expect(tsPropertyIndex("application/json")).toBe(`"application/json"`); + expect(tsPropertyIndex("0invalid")).toBe(`"0invalid"`); + expect(tsPropertyIndex("inv@lid")).toBe(`"inv@lid"`); + expect(tsPropertyIndex("in.valid")).toBe(`"in.valid"`); + expect(tsPropertyIndex(-1)).toBe(`"-1"`); + expect(tsPropertyIndex("-1")).toBe(`"-1"`); }); }); @@ -428,42 +405,106 @@ describe("tsIsPrimitive", () => { }); test("array", () => { - expect(tsIsPrimitive(ts.factory.createArrayTypeNode(STRING))).toBe(false); + expect(tsIsPrimitive(`${STRING}[]`)).toBe(false); }); test("object", () => { - expect( - tsIsPrimitive( - ts.factory.createTypeLiteralNode([ts.factory.createPropertySignature(undefined, "foo", undefined, STRING)]), - ), - ).toBe(false); + expect(tsIsPrimitive(typeLiteral([propertySignature({ name: "foo", type: STRING, indent: INDENT })], ""))).toBe( + false, + ); + }); +}); + +describe("tsParenthesize", () => { + test("wraps unions and intersections", () => { + expect(tsParenthesize(`${STRING} | ${NUMBER}`)).toBe(`(${STRING} | ${NUMBER})`); + expect(tsParenthesize(`${STRING} & ${NUMBER}`)).toBe(`(${STRING} & ${NUMBER})`); + }); + + test("wraps function types", () => { + expect(tsParenthesize("(arg: string) => number")).toBe("((arg: string) => number)"); + }); + + test("wraps conditional types", () => { + expect(tsParenthesize("T extends string ? A : B")).toBe("(T extends string ? A : B)"); + }); + + test("leaves atomic types alone", () => { + expect(tsParenthesize(STRING)).toBe(STRING); + expect(tsParenthesize(`components["schemas"]["User"]`)).toBe(`components["schemas"]["User"]`); + expect(tsParenthesize(`${STRING}[]`)).toBe(`${STRING}[]`); + expect(tsParenthesize("Record")).toBe("Record"); + }); + + test("ignores operators inside generics, literals and comments", () => { + expect(tsParenthesize(`Omit`)).toBe(`Omit`); + expect(tsParenthesize(`"a | b"`)).toBe(`"a | b"`); + expect(tsParenthesize("{\n /** a | b */\n x: string;\n}")).toBe("{\n /** a | b */\n x: string;\n}"); + }); + + test("keeps a nullable function type from swallowing the union", () => { + expect(tsNullable(["(arg: string) => number"])).toBe("((arg: string) => number) | null"); + }); +}); + +describe("non-ASCII string literals", () => { + test("property names escape non-ASCII code units exactly like the compiler", () => { + expect(tsPropertyIndex("emoji🎉")).toBe('"emoji\\uD83C\\uDF89"'); + expect(tsPropertyIndex("café")).toBe('"caf\\u00E9"'); + expect(tsPropertyIndex("привет")).toBe('"\\u043F\\u0440\\u0438\\u0432\\u0435\\u0442"'); + }); + + test("escapes the characters the compiler gives named escapes", () => { + expect(tsPropertyIndex("a\nb")).toBe('"a\\nb"'); + expect(tsPropertyIndex("a\tb")).toBe('"a\\tb"'); + expect(tsPropertyIndex("a\u0000b")).toBe('"a\\0b"'); + expect(tsPropertyIndex("a\u2028b")).toBe('"a\\u2028b"'); + }); + + test("enum members and array literals escape non-ASCII", () => { + expect(tsEnumMember("café")).toBe('caf_ = "caf\\u00E9"'); + expect(tsArrayLiteralExpression("x", "string", ["café"])).toBe('const x: string[] = ["caf\\u00E9"];'); + }); + + test("$ref segments escape non-ASCII", () => { + expect(oapiRef("#/components/schemas/café")).toBe('components["schemas"]["caf\\u00E9"]'); + }); + + test("tsLiteral keeps non-ASCII verbatim (UTF-8 workaround)", () => { + // intentionally NOT escaped: mirrors createIdentifier(JSON.stringify(…)) + expect(tsLiteral("emoji🎉")).toBe('"emoji🎉"'); }); }); describe("tsUnion", () => { test("none", () => { - expect(astToString(tsUnion([])).trim()).toBe("never"); + expect(tsUnion([])).toBe("never"); }); test("one", () => { - expect(astToString(tsUnion([STRING])).trim()).toBe("string"); + expect(tsUnion([STRING])).toBe("string"); }); test("multiple (primitive)", () => { - expect(astToString(tsUnion([STRING, STRING, NUMBER, NULL, NUMBER, NULL])).trim()).toBe("string | number | null"); + expect(tsUnion([STRING, STRING, NUMBER, NULL, NUMBER, NULL])).toBe("string | number | null"); }); test("multiple (const)", () => { - expect(astToString(tsUnion([NULL, tsLiteral("red"), tsLiteral(42), tsLiteral(false)])).trim()).toBe( - `null | "red" | 42 | false`, - ); + expect(tsUnion([NULL, tsLiteral("red"), tsLiteral(42), tsLiteral(false)])).toBe(`null | "red" | 42 | false`); + }); + + test("collapses a redundant union instead of emitting a single-member union", () => { + // The AST implementation built a one-member union node here, which the + // TypeScript printer rendered as `(string)` in parenthesised positions. + // Emitting the bare keyword is equivalent and strictly cleaner. + expect(tsUnion([STRING, STRING])).toBe(STRING); + expect(tsIntersection([STRING, STRING])).toBe(STRING); + expect(`${tsParenthesize(tsUnion([STRING, STRING]))}[]`).toBe("string[]"); }); test("multiple (object types)", () => { - const obj = ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature(undefined, "foo", undefined, STRING), - ]); - expect(astToString(tsUnion([obj, obj, NULL])).trim()).toBe(`{ + const obj = typeLiteral([propertySignature({ name: "foo", type: STRING, indent: INDENT })], ""); + expect(tsUnion([obj, obj, NULL])).toBe(`{ foo: string; } | { foo: string; diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index b22f8ad18..64c79816b 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -1,13 +1,12 @@ import { fileURLToPath } from "node:url"; -import ts from "typescript"; -import openapiTS, { astToString, COMMENT_HEADER } from "../src/index.js"; +import openapiTS, { astToString, COMMENT_HEADER, tsComment, tsLiteral, tsUnion } from "../src/index.js"; import type { OpenAPITSOptions } from "../src/types.js"; import type { TestCase } from "./test-helpers.js"; const EXAMPLES_DIR = new URL("../examples/", import.meta.url); -const DATE = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Date")); -const BLOB = ts.factory.createTypeReferenceNode("Blob"); +const DATE = "Date"; +const BLOB = "Blob"; describe("Node.js API", () => { const tests: TestCase[] = [ @@ -573,7 +572,7 @@ export type operations = Record;`, * then use the `typescript` parser and it will tell you the desired * AST */ - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("DateOrTime")); + return "DateOrTime"; } // Previously, in order to access the schema in postTransform, @@ -593,13 +592,7 @@ export type operations = Record;`, return typeof enumMember === "string"; }) ) { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Set"), [ - ts.factory.createUnionTypeNode( - schema.enum.map((value) => { - return ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(value)); - }), - ), - ]); + return `Set<${tsUnion(schema.enum.map((value) => tsLiteral(value)))}>`; } }, }, @@ -691,21 +684,8 @@ export type operations = Record;`, } if (validationTags.length > 0) { - // Create a new property signature - const newProperty = ts.factory.updatePropertySignature( - property, - property.modifiers, - property.name, - property.questionToken, - property.type, - ); - - // Add JSDoc comment using the same format as addJSDocComment - const jsDocText = `*\n * ${validationTags.join("\n * ")}\n `; - - ts.addSyntheticLeadingComment(newProperty, ts.SyntaxKind.MultiLineCommentTrivia, jsDocText, true); - - return newProperty; + // Add a JSDoc block using the same format as addJSDocComment + return { ...property, comment: tsComment(validationTags, property.indent) }; } return property; diff --git a/packages/openapi-typescript/test/transform/components-object.test.ts b/packages/openapi-typescript/test/transform/components-object.test.ts index d0cd85e94..5f5676f0b 100644 --- a/packages/openapi-typescript/test/transform/components-object.test.ts +++ b/packages/openapi-typescript/test/transform/components-object.test.ts @@ -1,13 +1,12 @@ import { fileURLToPath } from "node:url"; -import ts from "typescript"; -import { astToString, NULL } from "../../src/lib/ts.js"; +import { astToString, NULL, tsUnion } from "../../src/lib/ts.js"; import transformComponentsObject, { isEnumSchema } from "../../src/transform/components-object.js"; import type { GlobalContext } from "../../src/types.js"; import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; const DEFAULT_OPTIONS = DEFAULT_CTX; -const DATE = ts.factory.createTypeReferenceNode("Date"); +const DATE = "Date"; describe("transformComponentsObject", () => { const tests: TestCase[] = [ @@ -833,7 +832,7 @@ export type ItemDTO = components['schemas']['ItemDTO']; transform(schemaObject) { if (schemaObject.format === "date-time") { return { - schema: ts.factory.createUnionTypeNode([DATE, NULL]), + schema: tsUnion([DATE, NULL]), questionToken: true, }; } diff --git a/packages/openapi-typescript/test/transform/paths-object.test.ts b/packages/openapi-typescript/test/transform/paths-object.test.ts index 46aeb320d..ea28ea9d2 100644 --- a/packages/openapi-typescript/test/transform/paths-object.test.ts +++ b/packages/openapi-typescript/test/transform/paths-object.test.ts @@ -371,6 +371,66 @@ describe("transformPathsObject", () => { patch?: never; trace?: never; }; +}`, + options: { ...DEFAULT_OPTIONS, pathParamsAsTypes: true }, + }, + ], + [ + "options > pathParamsAsTypes escapes characters that would break the template literal", + { + given: { + "/a`b/{id}": { + parameters: [ + { + name: "id", + in: "path", + schema: { type: "string" }, + }, + ], + get: { + parameters: [], + responses: { 200: { description: "OK" } }, + }, + }, + }, + want: `{ + [path: \`/a\\\`b/\${string}\`]: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }`, options: { ...DEFAULT_OPTIONS, pathParamsAsTypes: true }, },