From 26f4233d4e4e47fb81802de09ef133fefed59873 Mon Sep 17 00:00:00 2001 From: im10furry Date: Fri, 11 Sep 2026 03:26:03 -0400 Subject: [PATCH] feat!: generate TypeScript from string templates Replace the TypeScript compiler API (ts.factory, createPrinter, createSourceFile) with raw string templates, so the generator no longer depends on TypeScript at runtime. TypeScript 7 ships no classic compiler API, which currently breaks the package twice over: the ^5.x peer range makes installation fail with ERESOLVE, and forcing past it crashes with "Cannot read properties of undefined (reading 'createKeywordTypeNode')". Generation now emits source text directly, reproducing the printer's formatting byte for byte: indentation, comment layout and trailing whitespace, parenthesisation of nested unions/intersections, multi-line tuples, and createStringLiteral's escaping of non-ASCII code units. typescript is dropped from peerDependencies and is never resolved, so the package works under TypeScript 5, 6 and 7 alike. Also fixes two cases that produced invalid TypeScript: a multi-line x-enum-descriptions entry leaked a bare token into the enum body, and pathParamsAsTypes emitted an unterminated template literal for a URL containing a backtick. BREAKING CHANGE: openapiTS() resolves to a string instead of ts.Node[], and the transform/postTransform/transformProperty hooks exchange strings and plain objects instead of AST nodes. --- .../string-templates-drop-typescript-dep.md | 50 + docs/introduction.md | 2 +- docs/node.md | 85 +- packages/openapi-typescript/package.json | 3 - packages/openapi-typescript/src/index.ts | 8 +- packages/openapi-typescript/src/lib/ts.ts | 1071 +++++++++++------ packages/openapi-typescript/src/lib/utils.ts | 21 +- .../src/transform/components-object.ts | 108 +- .../src/transform/header-object.ts | 49 +- .../openapi-typescript/src/transform/index.ts | 123 +- .../src/transform/media-type-object.ts | 8 +- .../src/transform/operation-object.ts | 114 +- .../src/transform/parameter-object.ts | 8 +- .../src/transform/parameters-array.ts | 90 +- .../src/transform/path-item-object.ts | 73 +- .../src/transform/paths-enum.ts | 7 +- .../src/transform/paths-object.ts | 107 +- .../src/transform/request-body-object.ts | 101 +- .../src/transform/response-object.ts | 131 +- .../src/transform/responses-object.ts | 49 +- .../src/transform/schema-object.ts | 352 +++--- .../src/transform/webhooks-object.ts | 38 +- packages/openapi-typescript/src/types.ts | 34 +- .../openapi-typescript/test/lib/ts.test.ts | 407 ++++--- .../openapi-typescript/test/node-api.test.ts | 34 +- .../test/transform/components-object.test.ts | 7 +- .../test/transform/paths-object.test.ts | 60 + 27 files changed, 1869 insertions(+), 1271 deletions(-) create mode 100644 .changeset/string-templates-drop-typescript-dep.md 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 }, },