From a8e173829dae127c15ebd7b8dfce38712f3fe712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Belli?= Date: Sat, 12 Sep 2026 13:04:28 +0200 Subject: [PATCH] feat!: support TypeScript 6 and 7 without a runtime compiler dependency Extract the TypeScript work from darkbasic onto upstream main. Combine the TS6 adaptation in 219be04e with the compiler-free generator in 699ab089, based on upstream PRs #2774 and #2868. Keep this PR independent of the fork's pnpm upgrade, dependency/security updates, and required-only allOf feature. Preserve main's ordinary allOf behavior and generated examples. Generate TypeScript source through string builders instead of the classic compiler API. Remove the generator's TypeScript peer dependency so CLI, ESM, and CommonJS generation work with TS5, TS6, TS7, or no compiler installed. Keep TypeScript as a development dependency for builds and semantic assertions, using TS6.0.3 through the shared workspace catalog. Carry the enhanced Readable/Writable implementation into both the helpers package and generated read/write helpers. Preserve call signatures and built-in methods while resolving markers on other data properties. Keep callable arguments, results, and attached properties opaque. Resolve readonly collection methods and iterators through their element types, preserving readonly indices, length, required numeric properties, and additional data. Retain existing mutable-tuple behavior and lazy recursive array support, with no tuple reconstruction or recursion depth machinery. Keep the any fast path and inline generated readonly-data mappings to avoid collisions with root types named ReadonlyArrayData. Add the string-emitter corrections developed beyond upstream #2868: - Preserve outer array dimensions independently of item source text, including nested arrays, tuples, literals, and composed item schemas. - Parenthesize readonly arrays and lower-precedence type expressions correctly, including rest elements and custom hook-returned types. - Account for quoted strings, templates, comments, and all JavaScript line-comment terminators when composing type expressions. - Escape template literal paths and unusual parameter names safely. - Deduplicate footer helpers by their complete declarations so enum values or comments mentioning helper names cannot suppress output. - Let property hooks control readonly for ordinary properties and $defs. - Recognize root object types with comments or inline formatting and use aliases for nonempty object-shaped $defs under postTransform, including custom mapped types, while preserving empty-root fallback behavior. - Reject obsolete AST inputs and printer options in astToString instead of silently accepting incompatible calls. Correct CommonJS declarations to describe the actual default and named exports by disabling unbuild's incompatible declaration interop transform. Publish @types/js-yaml and json-schema-to-ts because Redocly's public declarations reference them; strict consumers must not have to supply these dependencies themselves. Preserve main's Redocly and tool versions. Make the breaking Node API explicit in English, Japanese, and Chinese: openapiTS resolves to source text, transform/postTransform exchange source strings, transformProperty uses a structured property description, footer declarations are source strings or deferred operations, and inject is emitted verbatim. Replace AST-specific utilities with string builders. Keep astToString as a unary source-string normalizer. Remove deprecated compiler options, fix the nonstrict test rootDir, and use Headers.forEach in the fetch test harness. Test TS5 and TS6 across Node22 and Node24 in CI, including package/framework checks. Keep static lint separate and avoid rerunning helper assertions within a matrix job. Allow TS6 only for the tested existing unbuild, rollup-plugin-dts, and SvelteKit versions whose peer ranges still exclude it. Retain upstream pnpm10.30.3, action-setupv5, and the existing browser CI configuration. Add isolated installed-package checks for TS5.9.3, TS6.0.3, TS7.0.2, and no compiler under Node22/24. Pack both packages without publishing test or script sources, exercise CLI/ESM/CommonJS and all hooks, and compile public API fixtures and generated declarations with strict checking and skipLibCheck=false. Reuse the full helper assertion suite against packed helpers and both mutable/immutable generated helpers. Include negative assertions so accidental any or incorrect array depth cannot pass silently. Provide a major generator changeset and a separate helpers patch changeset so the generator's breaking migration notes do not appear as helper API changes. Update contribution guidance and preserve ordinary allOf tests. Validation on this extracted branch with main's dependency versions: - Frozen pnpm10.30.3 install and changeset release plan pass. - All four TS5.9.3/TS6.0.3 x Node22.23.2/24.19.0 combinations pass 841 tests: generator353, fetch451, react-query37; all eight Turbo tasks run uncached, including package builds, helper assertions, example typechecks, and package export checks. - Package and nonstrict typechecks pass in all four combinations. - All eight packed-consumer combinations pass, including TS7.0.2 and generation with no installed TypeScript compiler. - Static lint, documentation build, and Vue/Svelte/Next example typechecks under TS5 and TS6 pass. - Nine CLI-generated examples from cached schemas exactly match main's snapshots; no example updates are needed. - Independent correctness and maintainability reviews found no blockers; 1,620 allOf differential cases match main's types and callback traces. Browser, macOS, and Windows execution was not rerun locally. TS7 validation covers installed consumers and generated declarations; workspace tooling continues to use and test the classic TS5/TS6 compiler. Based on https://github.com/openapi-ts/openapi-typescript/pull/2774 Based on https://github.com/openapi-ts/openapi-typescript/pull/2868 Related to https://github.com/openapi-ts/openapi-typescript/issues/2841 Co-authored-by: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Co-authored-by: im10furry --- .../string-templates-drop-typescript-dep.md | 64 + .changeset/typescript-6-read-write-helpers.md | 16 + .github/workflows/ci.yml | 43 +- docs/introduction.md | 2 +- docs/ja/node.md | 77 +- docs/node.md | 95 +- docs/tsconfig.json | 1 - docs/zh/node.md | 75 +- .../examples/nextjs/tsconfig.json | 1 - .../examples/vue-3/tsconfig.app.json | 1 - packages/openapi-fetch/test/helpers.ts | 18 +- .../test/no-strict-null-checks/tsconfig.json | 1 + .../schemas/read-write.d.ts | 12 +- packages/openapi-fetch/tsconfig.json | 1 - .../openapi-typescript-helpers/.npmignore | 1 + packages/openapi-typescript-helpers/README.md | 6 + .../openapi-typescript-helpers/package.json | 10 +- .../openapi-typescript-helpers/src/index.ts | 54 +- .../test/readable-writable.test-d.ts | 230 ++++ .../openapi-typescript-helpers/tsconfig.json | 2 +- packages/openapi-typescript/CONTRIBUTING.md | 10 +- packages/openapi-typescript/README.md | 2 +- packages/openapi-typescript/build.config.ts | 7 + packages/openapi-typescript/package.json | 7 +- .../scripts/test-consumer.mjs | 84 ++ packages/openapi-typescript/src/index.ts | 10 +- packages/openapi-typescript/src/lib/ts.ts | 1141 +++++++++++------ 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 | 151 ++- .../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 | 116 +- .../src/transform/request-body-object.ts | 101 +- .../src/transform/response-object.ts | 131 +- .../src/transform/responses-object.ts | 49 +- .../src/transform/schema-object.ts | 350 ++--- .../src/transform/webhooks-object.ts | 38 +- packages/openapi-typescript/src/types.ts | 36 +- .../test/array-generation.test.ts | 96 ++ .../test/fixtures/consumer/api.cts | 7 + .../test/fixtures/consumer/api.mts | 17 + .../test/fixtures/consumer/assertions.ts | 40 + .../test/fixtures/consumer/runtime.mjs | 96 ++ .../openapi-typescript/test/lib/ts.test.ts | 571 ++++++--- .../openapi-typescript/test/node-api.test.ts | 46 +- .../test/property-hooks.test.ts | 109 ++ .../test/read-write-helpers.test.ts | 427 ++++++ .../test/root-definitions.test.ts | 79 ++ .../openapi-typescript/test/test-helpers.ts | 28 + .../test/transform/components-object.test.ts | 7 +- .../test/transform/paths-object.test.ts | 84 +- .../test/transform/schema-object/enum.test.ts | 4 +- packages/openapi-typescript/tsconfig.json | 2 +- pnpm-lock.yaml | 172 +-- pnpm-workspace.yaml | 9 +- 61 files changed, 3737 insertions(+), 1478 deletions(-) create mode 100644 .changeset/string-templates-drop-typescript-dep.md create mode 100644 .changeset/typescript-6-read-write-helpers.md create mode 100644 packages/openapi-typescript-helpers/test/readable-writable.test-d.ts create mode 100644 packages/openapi-typescript/scripts/test-consumer.mjs create mode 100644 packages/openapi-typescript/test/array-generation.test.ts create mode 100644 packages/openapi-typescript/test/fixtures/consumer/api.cts create mode 100644 packages/openapi-typescript/test/fixtures/consumer/api.mts create mode 100644 packages/openapi-typescript/test/fixtures/consumer/assertions.ts create mode 100644 packages/openapi-typescript/test/fixtures/consumer/runtime.mjs create mode 100644 packages/openapi-typescript/test/property-hooks.test.ts create mode 100644 packages/openapi-typescript/test/read-write-helpers.test.ts create mode 100644 packages/openapi-typescript/test/root-definitions.test.ts diff --git a/.changeset/string-templates-drop-typescript-dep.md b/.changeset/string-templates-drop-typescript-dep.md new file mode 100644 index 000000000..fd9b0e11f --- /dev/null +++ b/.changeset/string-templates-drop-typescript-dep.md @@ -0,0 +1,64 @@ +--- +"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, +- supports TypeScript 6 in both the standalone and generated read/write + helpers, including callable and readonly collection handling. + +`Readable` and `Writable` preserve call signatures and built-in methods +while resolving visibility markers on other data properties. Readonly array +methods and iterators expose resolved elements without making positional or +additional data properties writable. These fixes also apply under TypeScript 5. + +Output fixes include: + +- `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. +- Nested immutable arrays retain their parentheses, and arrays of composed + item types retain their outer array dimension. +- CommonJS declarations now match the actual default and named exports, and + the published package includes the type dependencies needed by Redocly's + public declarations for consumers with `skipLibCheck: false`. + +**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, readonly, 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. +- `astToString()` now joins source strings and ensures a trailing newline. AST + nodes and the former printer options argument are no longer supported; + passing options throws instead of silently ignoring them. +- With `postTransform` configured, nonempty object-shaped `$defs` roots use type + aliases so custom mapped types are valid. Root detection accepts surrounding + comments and inline formatting while retaining the empty-root fallback. diff --git a/.changeset/typescript-6-read-write-helpers.md b/.changeset/typescript-6-read-write-helpers.md new file mode 100644 index 000000000..defe8fc73 --- /dev/null +++ b/.changeset/typescript-6-read-write-helpers.md @@ -0,0 +1,16 @@ +--- +"openapi-typescript-helpers": patch +--- + +Support TypeScript 6 alongside TypeScript 5 and 7. + +Fix `Readable` and `Writable` to preserve call signatures and methods on +objects such as `Date` and `RegExp`, while resolving visibility markers on +their other data properties. Callable arguments, return types, and attached +properties remain unchanged. These fixes also apply under TypeScript 5. + +Resolve readonly-array methods and iteration through their element types while +preserving readonly indices, length, required numeric properties, and extra +data properties. Retain existing mutable-tuple behavior, support generics and +recursive arrays/tuples, and use the mutable-array `never` rule for excluded +readonly elements. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014c1f885..872bd5dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,43 @@ jobs: - uses: pnpm/action-setup@v5 with: run_install: true - - run: pnpm run lint + - run: pnpm --recursive run --if-present lint:js test-node-versions: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22, 24] + typescript-version: [5, 6] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - uses: pnpm/action-setup@v5 + - name: Install locked dependencies (TypeScript 6) + if: matrix.typescript-version == 6 + run: pnpm install --frozen-lockfile + - name: Install TypeScript 5 across the workspace + if: matrix.typescript-version == 5 + run: | + sed -i 's/^ typescript: .*/ typescript: ^5.9.3/' pnpm-workspace.yaml + pnpm install --no-frozen-lockfile + - run: pnpm exec tsc --version + - run: pnpm run build + - name: Typecheck packages + run: | + # The helpers package runs its type assertions through pnpm test below. + pnpm --recursive --filter '!openapi-typescript-helpers' run --if-present lint:ts + pnpm --recursive run --if-present lint:ts-no-strict + - run: pnpm test + - name: Typecheck framework examples + run: | + pnpm --filter '@example/openapi-fetch-vue-3' type-check + pnpm --filter '@example/openapi-fetch-sveltekit' check + pnpm --filter '@example/openapi-fetch-nextjs' exec tsc --noEmit + test-consumers: + runs-on: ubuntu-24.04 + timeout-minutes: 15 strategy: matrix: node-version: [22, 24] @@ -36,7 +70,12 @@ jobs: - uses: pnpm/action-setup@v5 with: run_install: true - - run: pnpm test + - run: pnpm --filter openapi-typescript --filter openapi-typescript-helpers build + - name: Test TypeScript 5, 6, 7 and compiler-free consumers + run: | + for version in 5.9.3 6.0.3 7.0.2 none; do + pnpm --filter openapi-typescript test:consumer -- "$version" + done test-e2e: runs-on: ubuntu-latest steps: 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/ja/node.md b/docs/ja/node.md index 2fedf1716..2cb706c60 100644 --- a/docs/ja/node.md +++ b/docs/ja/node.md @@ -10,7 +10,7 @@ Node APIは、動的に生成されたスキーマを扱う場合や、より大 ## セットアップ ```bash -npm i --save-dev openapi-typescript typescript +npm i --save-dev openapi-typescript ``` ::: tip 推奨 @@ -31,18 +31,17 @@ Node.js APIは、`URL`、`string`、またはJSONオブジェクトを入力と また、 `Readable` ストリームや`Buffer` 型も受け付け、これらは文字列として解決されます(ドキュメント全体がないと検証、バンドル、型生成ができません)。 -Node APIはTypeScript の AST を含む `Promise` を返します。その後、必要に応じてASTをトラバース、操作、または修正できます。 +Node APIは、生成されたTypeScriptソースの文字列を含む `Promise` を返します。そのままファイルに書き込めます。型の生成に `typescript` パッケージは必要ありません。 -TypeScript ASTを文字列に変換するには、[TypeScriptのprinterのラッパー](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#re-printing-sections-of-a-typescript-file)である `astToString()` ヘルパーを使用できます: +`astToString()` は引き続き利用できますが、現在はソース文字列を受け取り、末尾に改行を追加するヘルパーです。TypeScript ASTやプリンターのオプションは使用できません。 ::: 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)); // (任意)ファイルに書き込み fs.writeFileSync("./my-schema.ts", contents); @@ -74,7 +73,7 @@ const redocly = await createConfig( // オプション2: redocly.yamlファイルから読み込み const redocly = await loadConfig({ configPath: "redocly.yaml" }); -const ast = await openapiTS(mySchema, { redocly }); +const types = await openapiTS(mySchema, { redocly }); ``` ::: @@ -96,7 +95,7 @@ Node APIは、 `camelCase` 形式で[CLI フラグ](./cli#%E3%83%95%E3%83%A9%E3% `transform()` と `postTransform()` オプションを使用して、デフォルトのスキーマオブジェクト変換を独自のものに上書きできます。これは、スキーマの特定の部分に対して非標準的な変更を提供する場合に役立ちます。 - `transform()` はTypeScriptへの **変換前** に実行されます(OpenAPIノードを扱います) -- `postTransform()` はTypeScriptへの **変換後** に実行されます(TypeScript ASTを扱います) +- `postTransform()` はTypeScriptへの **変換後** に実行されます(生成された型の文字列を扱います) #### 例: `Date` 型 @@ -115,19 +114,14 @@ properties: ```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; } }, }); @@ -169,19 +163,14 @@ Body_file_upload: ```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; } }, }); @@ -224,20 +213,15 @@ Body_file_upload: ```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, }; } @@ -261,3 +245,26 @@ file?: Blob | null; // [!code ++] スキーマ内の任意の[Schema Object](https://spec.openapis.org/oas/latest.html#schema-object)は、このフォーマッタを通じて処理されます(リモートのものも含まれます!)。また、追加のコンテキストが役立つ場合があるので、`metadata` パラメータも必ず確認してください。 `format`のチェック以外にも、これを利用する方法は多数あります。この関数は **string** を返す必要があるため、任意のTypeScriptコード(独自のカスタム型も含む)を生成することができます。 + + +### transformProperty + +`transformProperty()` はプロパティの型変換後に実行され、`{ name, optional, readonly, type, comment?, indent }` オブジェクトを受け取ります。変更したオブジェクトを返すか、変更しない場合は `undefined` を返してください。`name` は必要に応じて引用符が付いた名前、`type` は型の文字列です。`readonly` を変更すると、スキーマや `immutable` オプションによるデフォルトを上書きできます。 + +```ts +import openapiTS, { tsComment } from "openapi-typescript"; + +const types = await openapiTS(mySchema, { + transformProperty(property, schemaObject) { + if (schemaObject.format === "date-time") { + return { + ...property, + readonly: true, + comment: tsComment(["@custom timestamp"], property.indent), + }; + } + }, +}); +``` + +`tsComment()` で作成したコメントの後に、スキーマ由来のJSDocが追加されます。このフックは Schema Object のプロパティと `$defs` に適用されます。 diff --git a/docs/node.md b/docs/node.md index bd837887a..b891d5b4a 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 + +Generation works in TypeScript 5, 6, and 7 projects and doesn’t require `typescript` to be installed. The generator no longer uses the TypeScript compiler API. The `astToString()` helper is still exported to join source fragments and ensure a trailing newline; it no longer accepts AST nodes or printer options. + +::: ::: 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); @@ -50,6 +53,14 @@ fs.writeFileSync("./my-schema.ts", contents); ::: +### Migrating from the AST API + +`openapiTS()` now returns source text. Write it directly, and replace AST-producing callbacks with callbacks that return type strings. For example, `ts.factory.createTypeReferenceNode("Date")` becomes `"Date"`. Code that traverses or rewrites AST nodes must be migrated explicitly. + +`astToString()` accepts only a string or an array of strings. Its old `fileName`, `sourceText`, and `formatOptions` argument has been removed and passing an options object throws an error. Apply any custom formatting or comment removal as a separate step. The `inject` option is emitted as source text instead of being parsed and printed by TypeScript. + +When `postTransform` is configured, nonempty object-shaped `$defs` roots use type aliases, including when the hook leaves the root unchanged. This allows mapped types in custom root definitions without converting them into invalid interfaces. + ### Redoc config A Redoc config isn’t required to use openapi-typescript. By default it extends the `"minimal"` built-in config. But if you want to modify the default settings, you’ll need to provide a fully-initialized Redoc config to the Node API. You can do this with the helpers in `@redocly/openapi-core`: @@ -74,7 +85,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 +108,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 +127,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 +176,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 +226,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 +265,8 @@ 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, readonly, type, comment?, indent }` object, or `undefined` to leave the property unchanged +- `readonly` initially reflects `immutable` and the schema’s read/write settings; the hook can override it for an individual property #### Example: JSDoc validation annotations @@ -297,10 +300,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 +328,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 +370,7 @@ 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, readonly, 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/docs/tsconfig.json b/docs/tsconfig.json index fff04e9d2..003357448 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", "esModuleInterop": true, "jsx": "preserve", "module": "ESNext", diff --git a/docs/zh/node.md b/docs/zh/node.md index f5c6da583..28edb59d6 100644 --- a/docs/zh/node.md +++ b/docs/zh/node.md @@ -10,7 +10,7 @@ Node.js API 对于处理动态创建的模式或在较大应用程序上下文 ## 安装 ```bash -npm i --save-dev openapi-typescript typescript +npm i --save-dev openapi-typescript ``` ::: tip 推荐 @@ -31,18 +31,17 @@ Node.js API 接受 `URL`、`string` 或 JSON 对象作为输入: 它还接受 `Readable` 流和 `Buffer` 类型,这些类型将被解析并视为字符串(无法在没有整个文档的情况下进行验证、捆绑和类型生成)。 -Node API 返回一个带有 TypeScript AST 的 `Promise`。然后,您可以按需遍历/操作/修改 AST。 +Node API 返回一个包含生成的 TypeScript 源码字符串的 `Promise`,可以直接写入文件。生成类型不需要安装 `typescript` 包。 -要将 TypeScript AST 转换为字符串,可以使用 `astToString()` 辅助函数,它是对 [TypeScript’s printer](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#re-printing-sections-of-a-typescript-file) 的简单封装: +`astToString()` 仍然可用,但现在只接受源码字符串并确保末尾有换行。它不再处理 TypeScript AST 或打印器选项。 ::: 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)); // (可选)写入文件 fs.writeFileSync("./my-schema.ts", contents); @@ -61,7 +60,7 @@ import { createConfig, loadConfig } from "@redocly/openapi-core"; import openapiTS from "openapi-typescript"; // 选项 1:在内存中创建配置 -const redoc = await createConfig( +const redocly = await createConfig( { apis: { "core@v2": { … }, @@ -72,9 +71,9 @@ const redoc = await createConfig( ); // 选项 2:从 redocly.yaml 文件加载 -const redoc = await loadConfig({ configPath: "redocly.yaml" }); +const redocly = await loadConfig({ configPath: "redocly.yaml" }); -const ast = await openapiTS(mySchema, { redoc }); +const types = await openapiTS(mySchema, { redocly }); ``` ::: @@ -96,7 +95,7 @@ Node API 支持所有 [CLI 参数](/zh/cli#命令行参数)(采用 `camelCase` 使用 `transform()` 和 `postTransform()` 选项覆盖默认的 Schema Object 转换器。这对于为模式的特定部分提供非标准修改很有用。 - `transform()` 在转换为 TypeScript 之前运行(您正在使用原始 OpenAPI 节点) -- `postTransform()` 在转换为 TypeScript 之后运行(您正在使用 TypeScript AST) +- `postTransform()` 在转换为 TypeScript 之后运行(您正在使用生成的类型字符串) #### 示例:`Date` 类型 @@ -119,17 +118,14 @@ properties: ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const DATE = 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; } }, }); @@ -171,17 +167,14 @@ Body_file_upload: ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const BLOB = 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; } }, }); @@ -224,18 +217,15 @@ Body_file_upload: ```ts [src/my-project.ts] import openapiTS from "openapi-typescript"; -import ts from "typescript"; -const BLOB = 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, }; } @@ -259,3 +249,26 @@ file?: Blob | null; // [!code ++] 您的模式中的任何 [Schema Object](https://spec.openapis.org/oas/latest.html#schema-object) 都将通过此格式化程序(甚至是远程的!)。还请务必检查 `metadata` 参数,以获取可能有用的其他上下文。 除了检查 `format` 之外,还有许多其他用途。由于此必须返回一个 **字符串**,因此您可以生成任何您想要的任意 TypeScript 代码(甚至是您自己的自定义类型)。 + + +### transformProperty + +`transformProperty()` 在属性类型转换后运行,接收 `{ name, optional, readonly, type, comment?, indent }` 对象。返回修改后的对象,或返回 `undefined` 保留原属性。`name` 是已经按需加引号的属性名,`type` 是类型字符串。修改 `readonly` 可以覆盖模式或 `immutable` 选项设置的默认值。 + +```ts +import openapiTS, { tsComment } from "openapi-typescript"; + +const types = await openapiTS(mySchema, { + transformProperty(property, schemaObject) { + if (schemaObject.format === "date-time") { + return { + ...property, + readonly: true, + comment: tsComment(["@custom timestamp"], property.indent), + }; + } + }, +}); +``` + +模式自身的 JSDoc 会附加在 `tsComment()` 生成的注释之后。此钩子适用于 Schema Object 的属性和 `$defs`。 diff --git a/packages/openapi-fetch/examples/nextjs/tsconfig.json b/packages/openapi-fetch/examples/nextjs/tsconfig.json index d6b46aba5..dc11a87c9 100644 --- a/packages/openapi-fetch/examples/nextjs/tsconfig.json +++ b/packages/openapi-fetch/examples/nextjs/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, - "baseUrl": ".", "esModuleInterop": true, "incremental": true, "isolatedModules": true, diff --git a/packages/openapi-fetch/examples/vue-3/tsconfig.app.json b/packages/openapi-fetch/examples/vue-3/tsconfig.app.json index ec2879985..b67e73cc5 100644 --- a/packages/openapi-fetch/examples/vue-3/tsconfig.app.json +++ b/packages/openapi-fetch/examples/vue-3/tsconfig.app.json @@ -6,7 +6,6 @@ "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "baseUrl": ".", "paths": { "#/*": ["./src/*"] } diff --git a/packages/openapi-fetch/test/helpers.ts b/packages/openapi-fetch/test/helpers.ts index bece1b17a..32513a2d8 100644 --- a/packages/openapi-fetch/test/helpers.ts +++ b/packages/openapi-fetch/test/helpers.ts @@ -24,18 +24,16 @@ export function createObservedClient): Record { - const iter = - headers instanceof Headers - ? headers - // @ts-expect-error FIXME: this is a missing "lib" in tsconfig.json but dunno what - .entries() - : Object.entries(headers); - const result: Record = {}; - for (const [k, v] of iter) { - result[k] = v; + if (!(headers instanceof Headers)) { + return { ...headers }; } + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); return result; } diff --git a/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json b/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json index bd513a8ea..661f12fa5 100644 --- a/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json +++ b/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "rootDir": "../..", "strictNullChecks": false }, "include": ["."], diff --git a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts index 2154b1e38..e4e18ad87 100644 --- a/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts +++ b/packages/openapi-fetch/test/read-write-visibility/schemas/read-write.d.ts @@ -9,10 +9,18 @@ export type $Read = { 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 ? { +export type Readable = 0 extends 1 & T ? any : T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends readonly (infer E)[] ? Readable<{ + [K in keyof T as K extends number ? number extends K ? never : K : K extends keyof readonly unknown[] ? never : K]: T[K]; +}> & { + readonly length: T["length"]; +} & readonly Readable[] : T extends (...args: never[]) => unknown ? T : 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 ? { +export type Writable = 0 extends 1 & T ? any : T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends readonly (infer E)[] ? Writable<{ + [K in keyof T as K extends number ? number extends K ? never : K : K extends keyof readonly unknown[] ? never : K]: T[K]; +}> & { + readonly length: T["length"]; +} & readonly Writable[] : T extends (...args: never[]) => unknown ? T : 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; diff --git a/packages/openapi-fetch/tsconfig.json b/packages/openapi-fetch/tsconfig.json index e17318259..9d0e58a86 100644 --- a/packages/openapi-fetch/tsconfig.json +++ b/packages/openapi-fetch/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "allowSyntheticDefaultImports": true, "declaration": true, - "downlevelIteration": false, "esModuleInterop": true, "lib": ["ESNext", "DOM"], "module": "NodeNext", diff --git a/packages/openapi-typescript-helpers/.npmignore b/packages/openapi-typescript-helpers/.npmignore index f8f90f0a3..a0d592097 100644 --- a/packages/openapi-typescript-helpers/.npmignore +++ b/packages/openapi-typescript-helpers/.npmignore @@ -2,4 +2,5 @@ *.config.* biome.json src +test tsconfig*.json diff --git a/packages/openapi-typescript-helpers/README.md b/packages/openapi-typescript-helpers/README.md index 0fbf9c367..72ea62bad 100644 --- a/packages/openapi-typescript-helpers/README.md +++ b/packages/openapi-typescript-helpers/README.md @@ -3,3 +3,9 @@ Helper utilities that power `openapi-fetch` but are generically-available for any project. This package isn’t as well-documented as the others, so it’s a bit “use at your own discretion.” + +`Readable` resolves `$Read` markers and excludes `$Write` properties for responses. `Writable` resolves `$Write` markers and excludes `$Read` properties for requests. Both recurse through objects and arrays, including recursive JSON types. Array methods and iteration expose resolved elements, and array mutability is preserved. + +Existing tuple handling is retained: mutable tuples resolve to arrays of their element union; readonly tuples retain fixed positional properties and length, not full tuple bounds or rest-position constraints. Readonly collections also retain and resolve additional data properties. Excluded array elements resolve to `never`, including in readonly collections. + +Callable types remain unchanged, including their call signatures, arguments, return types, and attached properties. Visibility markers inside those types are intentionally left intact. Object types such as `Date` and `RegExp` are traversed, preserving their callable methods while resolving markers on other properties. diff --git a/packages/openapi-typescript-helpers/package.json b/packages/openapi-typescript-helpers/package.json index 13d34b53f..7b8a21633 100644 --- a/packages/openapi-typescript-helpers/package.json +++ b/packages/openapi-typescript-helpers/package.json @@ -28,12 +28,14 @@ }, "scripts": { "build": "unbuild", - "format": "biome format src --write", + "format": "biome format src test --write", "lint": "pnpm run lint:js && pnpm run lint:ts", - "lint:js": "biome check src", - "lint:ts": "tsc --noEmit" + "lint:js": "biome check src test", + "lint:ts": "pnpm run test:types", + "test": "pnpm run test:types", + "test:types": "tsc --noEmit" }, "devDependencies": { - "typescript": "5.9.3" + "typescript": "catalog:" } } diff --git a/packages/openapi-typescript-helpers/src/index.ts b/packages/openapi-typescript-helpers/src/index.ts index 391e0a694..7f34c804d 100644 --- a/packages/openapi-typescript-helpers/src/index.ts +++ b/packages/openapi-typescript-helpers/src/index.ts @@ -210,38 +210,68 @@ export type $Read = { readonly $read: T }; /** Marker type for writeOnly properties (excluded from response bodies) */ export type $Write = { readonly $write: T }; +// Keep own data (including numeric literal keys), excluding the broad index and standard array members. +type ReadonlyArrayData = { + [K in keyof T as K extends number + ? number extends K + ? never + : K + : K extends keyof (readonly unknown[]) + ? never + : K]: T[K]; +}; + +// Fast path for `any` in generic clients. +// Rebuild readonly array methods from resolved elements, keeping their own properties and length. + /** * Resolve type for reading (responses): strips $Write properties, unwraps $Read * - $Read → T (readable), continues recursion * - $Write → never (excluded from response) + * - mutable arrays/tuples → resolve elements into an array (the existing tuple projection) + * - readonly arrays/tuples → preserve readonly data properties and length, resolving method elements + * - callable → unchanged, including arguments, return types, and attached properties * - object → recursively resolve */ -export type Readable = - T extends $Write +export type Readable = 0 extends 1 & T + ? any + : 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; + : T extends readonly (infer E)[] + ? Readable> & { readonly length: T["length"] } & readonly Readable[] + : T extends (...args: never[]) => unknown + ? T + : T extends object + ? { [K in keyof T as NonNullable extends $Write ? never : K]: Readable } + : T; /** * Resolve type for writing (requests): strips $Read properties, unwraps $Write * - $Write → T (writable), continues recursion * - $Read → never (excluded from request) + * - mutable arrays/tuples → resolve elements into an array (the existing tuple projection) + * - readonly arrays/tuples → preserve readonly data properties and length, resolving method elements + * - callable → unchanged, including arguments, return types, and attached properties * - object → recursively resolve */ -export type Writable = - T extends $Read +export type Writable = 0 extends 1 & T + ? any + : 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; + : T extends readonly (infer E)[] + ? Writable> & { readonly length: T["length"] } & readonly Writable[] + : T extends (...args: never[]) => unknown + ? T + : 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; diff --git a/packages/openapi-typescript-helpers/test/readable-writable.test-d.ts b/packages/openapi-typescript-helpers/test/readable-writable.test-d.ts new file mode 100644 index 000000000..b174b23cf --- /dev/null +++ b/packages/openapi-typescript-helpers/test/readable-writable.test-d.ts @@ -0,0 +1,230 @@ +import type { $Read, $Write, Readable, Writable } from "../src/index.js"; + +type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type ExactEqual = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +// Generic clients can use `any`; resolving it must terminate without widening `unknown`. +export type ReadableAny = Expect, any>>; +export type WritableAny = Expect, any>>; +export type ReadableUnknown = Expect, unknown>>; +export type WritableUnknown = Expect, unknown>>; + +export function readableGeneric[]>(value: Readable) { + const first: string = value[0]; + // @ts-expect-error constrained generics resolve markers without widening to any + const _invalid: number = value[0]; + return first; +} +export function writableGeneric[]>(value: Writable) { + const first: string = value[0]; + // @ts-expect-error constrained generics resolve markers without widening to any + const _invalid: number = value[0]; + return first; +} +export function readableGenericObject }>(value: Readable) { + const name: string = value.name; + // @ts-expect-error object constraints resolve markers without widening to any + const _invalid: number = value.name; + return name; +} +export function writableGenericObject }>(value: Writable) { + const name: string = value.name; + // @ts-expect-error object constraints resolve markers without widening to any + const _invalid: number = value.name; + return name; +} + +type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; +type NestedArray = (string | NestedArray)[]; +export const readableJson: Readable = ["one", { two: [2] }]; +export const writableJson: Writable = ["one", { two: [2] }]; +export const readableNestedArray: Readable = ["one", ["two"]]; +export const writableNestedArray: Writable = ["one", ["two"]]; + +type RecursiveTuple = [string | RecursiveTuple]; +type RecursiveOptionalTuple = [string, RecursiveOptionalTuple?]; +type RecursiveRestTuple = [string, ...RecursiveRestTuple[]]; +type RecursiveReadonlyTuple = readonly [string | RecursiveReadonlyTuple]; +type RecursiveReadonlyOptionalTuple = readonly [string, RecursiveReadonlyOptionalTuple?]; +type RecursiveReadonlyRestTuple = readonly [string, ...RecursiveReadonlyRestTuple[]]; +export const readableRecursiveTuple: Readable = [[["leaf"]]]; +export const writableRecursiveTuple: Writable = [[["leaf"]]]; +export const readableRecursiveOptional: Readable = ["a", ["b"]]; +export const writableRecursiveOptional: Writable = ["a", ["b"]]; +export const readableRecursiveRest: Readable = ["a", ["b"]]; +export const writableRecursiveRest: Writable = ["a", ["b"]]; +export const readableRecursiveReadonlyTuple: Readable = [[["leaf"]]]; +export const writableRecursiveReadonlyTuple: Writable = [[["leaf"]]]; +export const readableRecursiveReadonlyOptional: Readable = ["a", ["b"]]; +export const writableRecursiveReadonlyOptional: Writable = ["a", ["b"]]; +export const readableRecursiveReadonlyRest: Readable = ["a", ["b"]]; +export const writableRecursiveReadonlyRest: Writable = ["a", ["b"]]; +// @ts-expect-error recursive mutable tuples still reject invalid leaves +export const invalidReadableRecursiveTuple: Readable = [[123]]; +// @ts-expect-error recursive readonly tuples still reject invalid leaves +export const invalidWritableRecursiveTuple: Writable = [[true]]; + +type FunctionType = (value: number) => string; +type GenericFunction = (value: T) => T; +interface OverloadedFunction { + (value: number): number; + (value: string): string; +} +interface MarkedFunction { + (value: $Write<{ id: $Read }>): $Read<{ secret: $Write }>; + readonly visible: $Read; + secret?: $Write; +} + +type VisibilityRecord = { readonly id: $Read; value: string; secret?: $Write }; +type ReadableRecord = { readonly id: number; value: string }; +type WritableRecord = { value: string; secret?: string } & { readonly id?: never }; +interface ArrayInterface extends Array {} +interface ReadonlyArrayInterface extends ReadonlyArray {} +type AugmentedArray = VisibilityRecord[] & { readonly brand?: string }; +type AugmentedReadonlyArray = readonly VisibilityRecord[] & { readonly brand?: string }; +type MarkedReadonlyArray = readonly VisibilityRecord[] & { id: $Read; secret: $Write }; +type ReadableNonEmptyArray = readonly $Read[] & { readonly 0: $Read<"first"> }; +type WritableNonEmptyArray = readonly $Write[] & { readonly 0: $Write<"first"> }; +export type ReadableNumericProperty = Expect[0], "first">>; +export type WritableNumericProperty = Expect[0], "first">>; +export const readableNonEmptyArray: Readable = ["first"]; +export const writableNonEmptyArray: Writable = ["first"]; +// @ts-expect-error required numeric properties must not disappear with the broad array index +export const readableEmptyArray: Readable = []; +// @ts-expect-error required numeric properties must not disappear with the broad array index +export const writableEmptyArray: Writable = []; +// @ts-expect-error narrowed numeric properties must retain their value constraints +export const readableWrongFirst: Readable = ["other"]; +// @ts-expect-error narrowed numeric properties must retain their value constraints +export const writableWrongFirst: Writable = ["other"]; + +export type ReadableDate = Expect, Date>>; +export type WritableDate = Expect, Date>>; +export type ReadableRegExp = Expect, RegExp>>; +export type WritableRegExp = Expect, RegExp>>; + +// Exact comparisons reject both `any` and a widened `(...args: any[]) => any` signature. +export type ReadableFunction = Expect, FunctionType>>; +export type WritableFunction = Expect, FunctionType>>; +export type ReadableGenericFunction = Expect, GenericFunction>>; +export type WritableGenericFunction = Expect, GenericFunction>>; +export type ReadableOverloadedFunction = Expect, OverloadedFunction>>; +export type WritableOverloadedFunction = Expect, OverloadedFunction>>; + +// Callables are opaque: markers in arguments, return types, and attached properties stay intact. +export type ReadableMarkedFunction = Expect, MarkedFunction>>; +export type WritableMarkedFunction = Expect, MarkedFunction>>; +export type ReadableWrappedFunction = Expect>, MarkedFunction>>; +export type WritableWrappedFunction = Expect>, MarkedFunction>>; + +export type ReadableMutableArray = Expect, ReadableRecord[]>>; +export type WritableMutableArray = Expect, WritableRecord[]>>; +export type ReadableReadonlyArray = Expect, readonly ReadableRecord[]>>; +export type WritableReadonlyArray = Expect, readonly WritableRecord[]>>; + +// Array subtypes must resolve method and iterator elements as well as numeric indices. +export type ReadableArrayInterface = Expect, ReadableRecord[]>>; +export type WritableArrayInterface = Expect, WritableRecord[]>>; +export type ReadableReadonlyArrayInterface = Expect, readonly ReadableRecord[]>>; +export type WritableReadonlyArrayInterface = Expect, readonly WritableRecord[]>>; +export type ReadableAugmentedArray = Expect, ReadableRecord[]>>; +export type WritableAugmentedArray = Expect, WritableRecord[]>>; +export type ReadableAugmentedReadonlyArray = Expect, readonly ReadableRecord[]>>; +export type WritableAugmentedReadonlyArray = Expect, readonly WritableRecord[]>>; +export type ReadableArrayMetadata = Expect["id"], number>>; +export type WritableArrayMetadata = Expect["secret"], string>>; +export type ReadableExcludedArrayMetadata = Expect< + Equal<"secret" extends keyof Readable ? true : false, false> +>; +export type WritableExcludedArrayMetadata = Expect["id"], undefined>>; + +declare const writableArrayInterface: Writable; +declare const writableAugmentedArray: Writable; +writableArrayInterface.push({ secret: "secret", value: "value" }); +writableAugmentedArray.push({ secret: "secret", value: "value" }); +// @ts-expect-error array subtype methods must reject read-only properties +writableArrayInterface.push({ id: 1, secret: "secret", value: "value" }); +// @ts-expect-error augmented array methods must reject read-only properties +writableAugmentedArray.push({ id: 1, secret: "secret", value: "value" }); + +// Retain the existing mutable-tuple projection instead of introducing eager tuple reconstruction. +export type ReadableMutableTuple = Expect, $Read]>, (string | number)[]>>; +export type WritableMutableTuple = Expect, $Write]>, (string | number)[]>>; +export type ReadableReadonlyTuple = Expect< + Equal, $Read]>, readonly [string, number]> +>; +export type WritableReadonlyTuple = Expect< + Equal, $Write]>, readonly [string, number]> +>; +export type ReadableOptionalTuple = Expect< + Equal, second?: $Read]>, (string | number | undefined)[]> +>; +export type WritableOptionalTuple = Expect< + Equal, second?: $Write]>, (string | number | undefined)[]> +>; +export type ReadableReadonlyRestTuple = Expect< + Equal, ...rest: $Read[]]>["0"], string | undefined> +>; +export type WritableReadonlyRestTuple = Expect< + Equal, ...rest: $Write[]]>["0"], string | undefined> +>; + +// Array iteration excludes marked elements; fixed writable slots retain the existing optional-never rule. +export type ReadableTupleVisibility = Expect< + Equal]>[number], ReadableRecord> +>; +export type WritableTupleVisibility = Expect< + Equal]>["1"], undefined> +>; +// @ts-expect-error excluded elements are never, consistently with mutable arrays +export const excludedReadonlyElement: Writable]> = [undefined]; + +// Array methods and iterators must expose resolved elements, just like numeric indexing. +export type ReadableArrayMapValue = Expect< + Equal["map"]>[0]>[0], ReadableRecord> +>; +export type WritableArrayMapValue = Expect< + Equal["map"]>[0]>[0], WritableRecord> +>; +export type ReadableArrayIteratorValue = Expect< + Equal< + ReturnType[typeof Symbol.iterator]> extends Iterator ? V : never, + ReadableRecord + > +>; +export type WritableArrayIteratorValue = Expect< + Equal< + ReturnType[typeof Symbol.iterator]> extends Iterator ? V : never, + WritableRecord + > +>; + +export type ReadableNestedMarkers = Expect< + Equal< + Readable<{ + response: $Read<{ value: string; secret: $Write }>; + request: $Write<{ value: string }>; + }>, + { response: { value: string } } + > +>; + +export type WritableNestedMarkers = Expect< + Equal< + Writable<{ + response: $Read<{ value: string }>; + request: $Write<{ id: $Read; value: string }>; + }>, + { request: { value: string } & { id?: never } } & { response?: never } + > +>; + +export type ReadableAugmentedDate = Expect< + Equal; secret: $Write }>, Date & { visible: string }> +>; + +export type WritableAugmentedDate = Expect< + Equal; secret: $Write }>, Date & { secret: string } & { id?: never }> +>; diff --git a/packages/openapi-typescript-helpers/tsconfig.json b/packages/openapi-typescript-helpers/tsconfig.json index 48e008b72..a76880589 100644 --- a/packages/openapi-typescript-helpers/tsconfig.json +++ b/packages/openapi-typescript-helpers/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "skipLibCheck": false }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/openapi-typescript/CONTRIBUTING.md b/packages/openapi-typescript/CONTRIBUTING.md index fa2d76367..60238d965 100644 --- a/packages/openapi-typescript/CONTRIBUTING.md +++ b/packages/openapi-typescript/CONTRIBUTING.md @@ -38,15 +38,11 @@ pnpm run dev This will compile the code as you change automatically. -#### Tip: use ASTExplorer.net! +#### Generating TypeScript -Working with the TypeScript AST can be daunting. Luckily, there’s [astexplorer.net](https://astexplorer.net) which makes it much more accessible. Rather than trying to build an AST from scratch (which is near impossible), instead: +The generator emits TypeScript source strings. Use the builders in `src/lib/ts.ts` for declarations, properties, arrays, unions, and comments. They handle quoting, indentation, and operator precedence consistently. -1. Switch to the **typescript** parser in the top menu -2. Type out code in the left-hand panel -3. Inspect the right-hand panel to see what the desired AST is. - -From there, you can refer to existing examples in the codebase. There may even be helper utilities in `src/lib/ts.ts` to make life easier. +Keep TypeScript a development dependency only. For changes to generated types, add compiler assertions as well as output snapshots: syntactically valid output can still accept or reject the wrong values. `pnpm run test:consumer -- 7.0.2` checks the built package in an isolated consumer; use `5.9.3`, `6.0.3`, or `none` for the other environments. #### Tip: Use Test-driven Development! diff --git a/packages/openapi-typescript/README.md b/packages/openapi-typescript/README.md index e6b65bd11..c498368af 100644 --- a/packages/openapi-typescript/README.md +++ b/packages/openapi-typescript/README.md @@ -25,7 +25,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/packages/openapi-typescript/build.config.ts b/packages/openapi-typescript/build.config.ts index fcba5abaa..3644060f6 100644 --- a/packages/openapi-typescript/build.config.ts +++ b/packages/openapi-typescript/build.config.ts @@ -11,4 +11,11 @@ export default defineBuildConfig({ // Don’t bundle .js files together to more closely match old exports (can remove in next major) output: { preserveModules: true }, }, + hooks: { + "rollup:dts:options"(_ctx, options) { + // Our CommonJS bundle exports an object with named exports and `.default`. + // unbuild's interop plugin incorrectly changes this declaration to `export =`. + options.plugins = options.plugins.filter((plugin) => plugin.name !== "fix-dts-default-cjs-exports-plugin"); + }, + }, }); diff --git a/packages/openapi-typescript/package.json b/packages/openapi-typescript/package.json index 2b322a039..324bc6e61 100644 --- a/packages/openapi-typescript/package.json +++ b/packages/openapi-typescript/package.json @@ -52,19 +52,19 @@ "prepack": "pnpm run build", "test": "pnpm run test:js && pnpm run test:examples && pnpm run test:exports", "test:js": "vitest run", + "test:consumer": "node ./scripts/test-consumer.mjs", "test:exports": "pnpm run build && attw --pack .", "test:examples": "tsc -p tsconfig.examples.json --noEmit", "update:examples": "pnpm run build && pnpm run download:schemas && vite-node ./scripts/update-examples.ts", "prepublish": "pnpm run build", "version": "pnpm run build" }, - "peerDependencies": { - "typescript": "^5.x" - }, "dependencies": { "@redocly/openapi-core": "^1.34.6", + "@types/js-yaml": "4.0.9", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", + "json-schema-to-ts": "3.1.1", "parse-json": "^8.3.0", "scule": "^1.3.0", "supports-color": "^10.2.2", @@ -72,7 +72,6 @@ }, "devDependencies": { "@types/degit": "2.8.6", - "@types/js-yaml": "4.0.9", "degit": "2.8.4", "execa": "catalog:", "strip-ansi": "7.2.0", diff --git a/packages/openapi-typescript/scripts/test-consumer.mjs b/packages/openapi-typescript/scripts/test-consumer.mjs new file mode 100644 index 000000000..a03822b2f --- /dev/null +++ b/packages/openapi-typescript/scripts/test-consumer.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execaSync } from "execa"; + +// Run after building the generator and helpers. Keep network-dependent consumer +// installs separate from the unit suite, and outside the workspace's resolution tree. +const version = process.argv.filter((argument) => argument !== "--")[2] ?? "none"; +assert.match(version, /^(?:none|[567]\.\d+\.\d+)$/, "Expected a TypeScript 5/6/7 version or 'none'"); +const packageDir = fileURLToPath(new URL("../", import.meta.url)); +const helpersDir = fileURLToPath(new URL("../../openapi-typescript-helpers/", import.meta.url)); +const consumer = await mkdtemp(join(tmpdir(), "openapi-typescript-consumer-")); +const env = { ...process.env, NODE_PATH: "", NODE_OPTIONS: "" }; +function run(command, args, cwd = consumer) { + // Execa resolves npm.cmd on Windows and escapes its arguments safely. + return execaSync(command, args, { + cwd, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + stripFinalNewline: false, + }).stdout; +} +function pack(directory) { + const [result] = JSON.parse( + run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", consumer], directory), + ); + assert( + result.files.some((file) => file.path === "dist/index.mjs"), + "Build packages before testing consumers", + ); + assert(!result.files.some((file) => /^(test|scripts)\//.test(file.path)), "Do not publish test sources"); + return join(consumer, result.filename); +} + +try { + const tarballs = [pack(packageDir), pack(helpersDir)]; + await writeFile(join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" })); + const compiler = version === "none" ? [] : [`typescript@${version}`, "@types/node@25.6.0"]; + process.stdout.write(`Testing packed packages with TypeScript ${version} on ${process.version}\n`); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--save-exact", ...tarballs, ...compiler]); + await cp(join(packageDir, "test/fixtures/consumer"), consumer, { recursive: true }); + process.stdout.write(run(process.execPath, ["runtime.mjs", version])); + + if (version !== "none") { + // Reuse the entire helper assertion suite against both the packed helper + // package and the inline generated helpers, including negative assertions. + const assertions = await readFile(join(helpersDir, "test/readable-writable.test-d.ts"), "utf8"); + for (const [name, module] of [ + ["package-helpers", "openapi-typescript-helpers"], + ["mutable-helpers", "./mutable.js"], + ["immutable-helpers", "./immutable.js"], + ]) { + await writeFile(join(consumer, `${name}.ts`), assertions.replace('"../src/index.js"', JSON.stringify(module))); + } + await writeFile( + join(consumer, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: false, + module: "NodeNext", + moduleResolution: "NodeNext", + target: "ES2022", + types: ["node"], + }, + include: ["*.ts", "*.mts", "*.cts"], + }), + ); + const tsc = join(consumer, "node_modules/typescript/bin/tsc"); + assert.equal(run(process.execPath, [tsc, "--version"]).trim(), `Version ${version}`); + process.stdout.write(run(process.execPath, [tsc, "--project", "tsconfig.json"])); + } + process.stdout.write("Consumer checks passed\n"); +} catch (error) { + process.stderr.write(error.stdout?.toString() ?? ""); + process.stderr.write(error.stderr?.toString() ?? ""); + throw error; +} finally { + await rm(consumer, { recursive: true, force: true }); +} diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index d042b81e4..a0869daed 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,8 +101,8 @@ export default async function openapiTS( }; const transformT = performance.now(); - const result = transformSchema(schema, ctx); - debug("Completed AST transformation for entire document", "ts", performance.now() - transformT); + const result = `${transformSchema(schema, ctx).join("\n")}\n`; + debug("Completed 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..ca1bf31c2 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. + * + * Builders preserve the compiler printer's formatting without depending on its + * 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,547 @@ 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; +/** Create a TS Omit type */ +export function tsOmit(type: TSNode, keys: string[]): TSNode { + return `Omit<${type}, ${tsUnion(keys.map((k) => tsLiteral(k)))}>`; } -/** 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 Record type */ +export function tsRecord(key: TSNode, value: TSNode): TSNode { + return `Record<${key}, ${value}>`; +} - // @ts-expect-error it’s OK to overwrite statements once - sourceFile.statements = ts.factory.createNodeArray(Array.isArray(ast) ? ast : [ast]); +/** 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)); +} - const printer = ts.createPrinter({ - newLine: ts.NewLineKind.LineFeed, - removeComments: false, - ...options?.formatOptions, - }); - return printer.printFile(sourceFile); +/** 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; - } - if (tsIsPrimitive(t)) { - encounteredTypes.add(kind); +export function tsParenthesize(expression: TSNode): TSNode { + return needsParentheses(expression) ? `(${expression})` : expression; +} + +/** Create an array type, preserving the precedence of its element type. */ +export function tsArray(elementType: TSNode): TSNode { + return `${needsParentheses(elementType, true) ? `(${elementType})` : elementType}[]`; +} + +/** 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 `//` comment, returning the index of its line terminator. */ +function skipLineComment(expression: TSNode, start: number): number { + for (let i = start + 2; i < expression.length; i++) { + const ch = expression[i]; + if (ch === "\n" || ch === "\r" || ch === "\u2028" || ch === "\u2029") { + 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] === '"' || expression[i] === "'") { + i = skipQuoted(expression, i, expression[i]); + } else if (expression[i] === "`") { + i = skipTemplate(expression, i); + } else if (expression[i] === "/" && expression[i + 1] === "*") { + const end = expression.indexOf("*/", i + 2); + i = end === -1 ? expression.length - 1 : end + 1; + } else if (expression[i] === "/" && expression[i + 1] === "/") { + i = skipLineComment(expression, i); + } else if (expression[i] === "{") { + braceDepth++; + } else if (expression[i] === "}") { + braceDepth--; + } + i++; } + i--; + continue; + } + if (ch === "`") { + return i; } - filteredTypes.push(t); } - return filteredTypes; + return expression.length - 1; } -export const enumCache = new Map(); +/** Remove surrounding whitespace/comments for type-shape checks, retaining inner source text. */ +export function stripTypeTrivia(expression: TSNode): TSNode { + let start = -1; + let end = 0; + for (let i = 0; i < expression.length; i++) { + const ch = expression[i]; + if (/\s/.test(ch)) { + continue; + } + if (ch === "/" && expression[i + 1] === "*") { + const close = expression.indexOf("*/", i + 2); + i = close === -1 ? expression.length - 1 : close + 1; + continue; + } + if (ch === "/" && expression[i + 1] === "/") { + i = skipLineComment(expression, i); + continue; + } + if (start === -1) { + start = i; + } + if (ch === '"' || ch === "'") { + i = skipQuoted(expression, i, ch); + } else if (ch === "`") { + i = skipTemplate(expression, i); + } + end = i + 1; + } + return start === -1 ? "" : expression.slice(start, end); +} -/** 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 }, -) { - 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; +/** + * 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, postfix = false): 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] === "/") { + i = skipLineComment(expression, i); + continue; + } + if (ch === "=" && expression[i + 1] === ">") { + // a function type is not bracketed, so it always needs parentheses here + if (depth === 0) { + return true; + } + 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; + } + // Array syntax binds more tightly than type operators, type queries and + // inference: `readonly T[][]` and `(readonly T[])[]` describe different types. + if (postfix && /[A-Za-z_$]/.test(ch)) { + const keyword = /^(readonly|keyof|typeof|unique|infer)\b/.exec(expression.slice(i)); + if (keyword && (i === 0 || !/[\w$.]/.test(expression[i - 1]))) { + 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; + } } } - 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; + return false; +} + +// --------------------------------------------------------------------------- +// 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; +} + +/** 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, + type: TSNode, + { export: isExport, comment = "", indent }: DeclarationOptions, +): TSNode { + return `${comment}${indent}${isExport ? "export " : ""}type ${name} = ${type};`; +} + +/** 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}}`; } -/** Create an exported TS array literal expression */ +/** 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); - } - - 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, - ), - ); + if (options?.injectFooter && !options.injectFooter.includes(HELPER_FLATTENED_DEEP_REQUIRED)) { + options.injectFooter.push(HELPER_FLATTENED_DEEP_REQUIRED); + } + + const arrayType = options?.readonly ? tsReadonlyArray(elementType, options.injectFooter) : tsArray(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 +718,116 @@ 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 = { + [K in keyof T]-?: FlattenedDeepRequired[number] : T[K]>; +};`; +const HELPER_WITH_REQUIRED = `type WithRequired = T & { + [P in K]-?: T[P]; +};`; +const HELPER_READONLY_ARRAY = `type ReadonlyArray = [ + Exclude +] extends [ + unknown[] +] ? 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))), - ]); -} +/** + * 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 TS Record type */ -export function tsRecord(key: ts.TypeNode, value: ts.TypeNode) { - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Record"), [key, value]); -} + add(member: TSNode): void { + this.members.push(member); + } -/** 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); + render(): TSNode { + return interfaceDecl("operations", this.members, { export: true, indent: "" }); } - return typeof index === "string" && JS_PROPERTY_INDEX_RE.test(index) - ? ts.factory.createIdentifier(index) - : ts.factory.createStringLiteral(String(index)); } -/** Create a union type */ -export function tsUnion(types: ts.TypeNode[]): ts.TypeNode { - if (types.length === 0) { - return NEVER; - } - if (types.length === 1) { - return types[0]; - } - return ts.factory.createUnionTypeNode(tsDedupe(types)); +/** 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 (!injectFooter.includes(HELPER_WITH_REQUIRED)) { + 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 +835,135 @@ 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 && !injectFooter.includes(HELPER_READONLY_ARRAY)) { + injectFooter.push(HELPER_READONLY_ARRAY); + } + 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); +} + +/** + * Normalize generated source into a complete file body. + * + * Generation now emits strings directly, so this only joins a list of top-level + * declarations and ensures a trailing newline. It remains exported + * for compatibility with code written against the AST-based API. + */ +export function astToString(ast: TSNode | TSNode[]): string; +export function astToString(ast: TSNode | TSNode[], ...removedOptions: unknown[]): string { + if (removedOptions.length > 0) { + throw new TypeError("astToString no longer accepts printer options. Format the returned source separately."); + } + if (typeof ast !== "string" && !(Array.isArray(ast) && ast.every((node) => typeof node === "string"))) { + throw new TypeError("astToString expects generated source strings; TypeScript AST nodes are no longer supported."); } - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("ReadonlyArray"), [type]); + 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..65043cc9c 100644 --- a/packages/openapi-typescript/src/transform/index.ts +++ b/packages/openapi-typescript/src/transform/index.ts @@ -1,6 +1,16 @@ 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, + stripTypeTrivia, + type TSNode, + tsParenthesize, + 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 +21,105 @@ 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 lines from a nonempty braced root, excluding outer compositions. + * Hook-produced $defs bodies use aliases, which also allow mapped types. + * Empty or nonbraced roots retain the Record fallback. + */ +function typeLiteralMembers(expression: TSNode): TSNode[] | undefined { + const source = stripTypeTrivia(expression); + if (!source.startsWith("{") || !source.endsWith("}") || tsParenthesize(source) !== source) { + return undefined; + } + const body = source.slice(1, -1); + // Keep generated member indentation while allowing hooks to supply inline or + // otherwise formatted object types. Compositions cannot become interfaces. + return stripTypeTrivia(body) + ? body + .replace(/^\r?\n/, "") + .replace(/\r?\n[ \t]*$/, "") + .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; -`; +// Fast path for `any` in generic clients. +// Rebuild readonly array methods from resolved elements, keeping their own properties and length. +// Inline the readonly data mapping to avoid reserving another generated root type name. +const READ_WRITE_HELPER_TYPES: TSNode[] = [ + `export type $Read = { + readonly $read: T; +};`, + `export type $Write = { + readonly $write: T; +};`, + `export type Readable = 0 extends 1 & T ? any : T extends $Write ? never : T extends $Read ? Readable : T extends (infer E)[] ? Readable[] : T extends readonly (infer E)[] ? Readable<{ + [K in keyof T as K extends number ? number extends K ? never : K : K extends keyof readonly unknown[] ? never : K]: T[K]; +}> & { + readonly length: T["length"]; +} & readonly Readable[] : T extends (...args: never[]) => unknown ? T : T extends object ? { + [K in keyof T as NonNullable extends $Write ? never : K]: Readable; +} : T;`, + `export type Writable = 0 extends 1 & T ? any : T extends $Read ? never : T extends $Write ? Writable : T extends (infer E)[] ? Writable[] : T extends readonly (infer E)[] ? Writable<{ + [K in keyof T as K extends number ? number extends K ? never : K : K extends keyof readonly unknown[] ? never : K]: T[K]; +}> & { + readonly length: T["length"]; +} & readonly Writable[] : T extends (...args: never[]) => unknown ? T : 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;`, +]; -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 || (root === "$defs" && ctx.postTransform) + ? 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 +134,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..06a73ba7f 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,81 +49,81 @@ 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); const param = pathParams[paramName]; + // rawPath is already escaped, including characters inside the + // placeholder name. Match that representation when replacing it. + const escapedMatch = escapeTemplateLiteral(match); switch (param?.schema?.type) { case "number": case "integer": - rawPath = rawPath.replace(match, "${number}"); + rawPath = rawPath.replace(escapedMatch, `\${number}`); break; case "boolean": - rawPath = rawPath.replace(match, "${boolean}"); + rawPath = rawPath.replace(escapedMatch, `\${boolean}`); break; default: - rawPath = rawPath.replace(match, "${string}"); + rawPath = rawPath.replace(escapedMatch, `\${string}`); 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..f357bfac0 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -1,31 +1,36 @@ 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, + tsArray, 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 +40,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 +59,8 @@ export function transformSchemaObjectWithComposition( schemaObject: SchemaObject | ReferenceObject, options: TransformNodeOptions, fromAdditionalProperties = false, -): ts.TypeNode { + indent = "", +): TSNode { /** * Unexpected types & edge cases */ @@ -77,14 +84,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 +131,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 +196,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 +219,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 +239,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 +271,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 +286,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 @@ -358,20 +367,22 @@ function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: Sc /** * 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 +406,24 @@ 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) { + 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 +436,51 @@ 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(`...${tsArray(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); + // Only prefixItems/legacy tuple items describe the whole array. A schema + // in `items` always describes one element, even when it emits an array, + // tuple, or union. Do not infer that distinction from the rendered text. + const finalType = isTupleShape ? itemType : tsArray(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 +495,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 +505,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 +516,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 +538,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor createDiscriminatorProperty(discriminator, { path: options.path ?? "", readonly: options.ctx.immutable, + indent: memberIndent, }), ); break; @@ -559,105 +579,127 @@ 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, + readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && !!readOnly), + 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: finalProperty.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, + readonly: options.ctx.immutable || (!options.ctx.readWriteMarkers && defReadOnly), + 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: finalProperty.readonly, + 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 +715,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 +727,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 +768,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 +781,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..2f9776b72 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,32 @@ 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; + /** Whether this property is readonly; hooks may override the generated default. */ + readonly: 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 +660,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 +729,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/array-generation.test.ts b/packages/openapi-typescript/test/array-generation.test.ts new file mode 100644 index 000000000..2b81948a4 --- /dev/null +++ b/packages/openapi-typescript/test/array-generation.test.ts @@ -0,0 +1,96 @@ +import openapiTS from "../src/index.js"; +import { expectTypeScriptToCompile } from "./test-helpers.js"; + +test.each([false, true])("array dimensions and precedence with immutable: %s", async (immutable) => { + const string = { type: "string" } as const; + const numbers = { type: "array", items: { type: "number" } } as const; + const schema = { + openapi: "3.1.0", + info: { title: "Array generation", version: "1.0.0" }, + components: { + schemas: { + Matrix: { type: "array", items: numbers }, + AnyOf: { type: "array", items: { anyOf: [string, numbers] } }, + ReversedAnyOf: { type: "array", items: { anyOf: [numbers, string] } }, + OneOf: { type: "array", items: { oneOf: [string, numbers] } }, + ReversedOneOf: { type: "array", items: { oneOf: [numbers, string] } }, + Tuples: { + type: "array", + items: { + anyOf: [ + { type: "array", prefixItems: [string] }, + { type: "array", prefixItems: [{ type: "number" }] }, + ], + }, + }, + TupleItems: { type: "array", items: { type: "array", prefixItems: [string] } }, + BoundedMatrix: { type: "array", minItems: 2, maxItems: 2, items: numbers }, + RestMatrix: { type: "array", minItems: 1, items: numbers }, + Transformed: { type: "array", items: { type: "string", format: "readonly-array" } }, + ConstantItems: { type: "array", items: { const: [1, 2] } }, + EmptyConstantItems: { type: "array", items: { const: [] } }, + }, + }, + }; + const generated = await openapiTS(JSON.stringify(schema), { + immutable, + arrayLength: true, + transform(schema) { + if (schema.format === "readonly-array") { + return "readonly number[]"; + } + }, + }); + expectTypeScriptToCompile( + generated, + ` +type Schemas = components["schemas"]; +const matrix: Schemas["Matrix"] = [[1, 2], [3]]; +${immutable ? "// @ts-expect-error immutable outer array" : ""} +matrix.push([4]); +${immutable ? "// @ts-expect-error immutable inner array" : ""} +matrix[0].push(4); +// @ts-expect-error nested arrays retain both dimensions +const flat: Schemas["Matrix"] = [1]; + +const anyOf: Schemas["AnyOf"] = ["one", [1, 2]]; +const reversedAnyOf: Schemas["ReversedAnyOf"] = anyOf; +const oneOf: Schemas["OneOf"] = anyOf; +const reversedOneOf: Schemas["ReversedOneOf"] = oneOf; +// @ts-expect-error compositions describe elements, not the outer array +const scalar: Schemas["AnyOf"] = "one"; +// @ts-expect-error number arrays are elements, not the whole schema +const numbers: Schemas["OneOf"] = [1]; +// @ts-expect-error reversing composition order keeps the array boundary +const reversedScalar: Schemas["ReversedOneOf"] = "one"; + +const tuples: Schemas["Tuples"] = [["one"], [1]]; +// @ts-expect-error a union of tuples is still an element type +const flatTuple: Schemas["Tuples"] = ["one"]; +const tupleItems: Schemas["TupleItems"] = [["one"], ["two"]]; +// @ts-expect-error tuples retain their positions +const incorrectTuple: Schemas["TupleItems"] = [[1]]; + +const bounded: Schemas["BoundedMatrix"] = [[1], [2]]; +// @ts-expect-error arrayLength limits the number of rows +const tooMany: Schemas["BoundedMatrix"] = [[1], [2], [3]]; +// @ts-expect-error nested arrayLength does not add an extra dimension +const tooDeep: Schemas["BoundedMatrix"] = [[[1]]]; +const rest: Schemas["RestMatrix"] = [[1], [2], [3]]; +// @ts-expect-error minItems still requires a first row +const emptyRest: Schemas["RestMatrix"] = []; + +const transformed: Schemas["Transformed"] = [[1]]; +// @ts-expect-error hook-returned readonly applies to the inner array +transformed[0].push(2); +// @ts-expect-error custom array types remain element types +const transformedFlat: Schemas["Transformed"] = [1]; +const constantItems: Schemas["ConstantItems"] = [[1, 2]]; +// @ts-expect-error literal tuples are array elements +const flatConstantItems: Schemas["ConstantItems"] = [1, 2]; +const emptyConstantItems: Schemas["EmptyConstantItems"] = [[]]; +// @ts-expect-error empty constant arrays cannot contain elements +const nonemptyConstantItems: Schemas["EmptyConstantItems"] = [[1]]; +`, + ); +}); diff --git a/packages/openapi-typescript/test/fixtures/consumer/api.cts b/packages/openapi-typescript/test/fixtures/consumer/api.cts new file mode 100644 index 000000000..287dc999a --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/consumer/api.cts @@ -0,0 +1,7 @@ +import openapiTS = require("openapi-typescript"); + +const result: Promise = openapiTS.default( + { openapi: "3.1.0", info: { title: "test", version: "1" } }, + { transformProperty: (property) => ({ ...property, readonly: true }) }, +); +result.then(openapiTS.astToString); diff --git a/packages/openapi-typescript/test/fixtures/consumer/api.mts b/packages/openapi-typescript/test/fixtures/consumer/api.mts new file mode 100644 index 000000000..0892b7dfc --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/consumer/api.mts @@ -0,0 +1,17 @@ +import openapiTS, { astToString, tsComment, type OpenAPITSOptions } from "openapi-typescript"; + +const options: OpenAPITSOptions = { + transform(schema) { + if (schema.format === "date-time") return { schema: "Date", questionToken: false }; + }, + postTransform(type) { + return type; + }, + transformProperty(property) { + return { ...property, readonly: false, comment: tsComment(["@custom"], property.indent) }; + }, +}; +const result: Promise = openapiTS({ openapi: "3.1.0", info: { title: "test", version: "1" } }, options); +result.then(astToString); +// @ts-expect-error obsolete printer options must require explicit migration +astToString("type T = string;", { formatOptions: { removeComments: true } }); diff --git a/packages/openapi-typescript/test/fixtures/consumer/assertions.ts b/packages/openapi-typescript/test/fixtures/consumer/assertions.ts new file mode 100644 index 000000000..094fe4ea4 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/consumer/assertions.ts @@ -0,0 +1,40 @@ +import type { Readable, Writable, components } from "./immutable.js"; +import type { components as MutableComponents } from "./mutable.js"; +import type { $defs as Definitions, components as RootComponents } from "./definitions.js"; + +type Schemas = components["schemas"]; +const matrix: Schemas["Matrix"] = [[1, 2], [3]]; +// @ts-expect-error the outer array is readonly +matrix.push([4]); +// @ts-expect-error the inner arrays are readonly +matrix[0].push(4); +const mixed: Schemas["Mixed"] = ["one", [1, 2]]; +// @ts-expect-error the outer array must not collapse into the item union +const missingOuter: Schemas["Mixed"] = "one"; +// @ts-expect-error a number is not an item of the outer array +const wrongDepth: Schemas["Mixed"] = [1]; + +const token: RootComponents["schemas"]["Token"] = "token"; +// @ts-expect-error comments around a mapped root must not erase its value type +const invalidToken: RootComponents["schemas"]["Token"] = 1; +declare const definitions: Definitions; +// @ts-expect-error mapped root definitions preserve readonly keys +definitions.Token = "other"; + +declare const response: Readable; +const dates: string[] = response.map((row) => row.date.toISOString()); +for (const row of response) { + row.id.toUpperCase(); + // @ts-expect-error write-only fields are absent in responses + row.secret; +} +declare const request: Writable; +request.secret.toUpperCase(); +// @ts-expect-error read-only fields are absent in requests +request.id.toUpperCase(); +declare const row: Schemas["Row"]; +row.mutable = { $read: "changed" }; + +const mutableMatrix: MutableComponents["schemas"]["Matrix"] = [[1]]; +mutableMatrix.push([2]); +mutableMatrix[0].push(3); diff --git a/packages/openapi-typescript/test/fixtures/consumer/runtime.mjs b/packages/openapi-typescript/test/fixtures/consumer/runtime.mjs new file mode 100644 index 000000000..5bc331573 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/consumer/runtime.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import openapiTS, { COMMENT_HEADER, tsComment } from "openapi-typescript"; + +const require = createRequire(import.meta.url); +const cjs = require("openapi-typescript"); +const packageDir = dirname(dirname(require.resolve("openapi-typescript"))); +const packageJSON = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8")); +assert.equal(packageJSON.dependencies?.typescript, undefined); +assert.equal(packageJSON.peerDependencies?.typescript, undefined); +if (process.argv[2] === "none") { + assert.throws(() => require.resolve("typescript", { paths: [packageDir] }), { code: "MODULE_NOT_FOUND" }); +} else { + assert.equal(require("typescript/package.json").version, process.argv[2]); +} + +const schema = { + openapi: "3.1.0", + info: { title: "Compiler compatibility", version: "1.0.0" }, + components: { + schemas: { + Row: { + type: "object", + required: ["id", "secret", "date"], + properties: { + id: { type: "string", readOnly: true }, + secret: { type: "string", writeOnly: true }, + date: { type: "string", format: "date-time" }, + mutable: { type: "string", readOnly: true }, + }, + }, + Rows: { type: "array", items: { $ref: "#/components/schemas/Row" } }, + Matrix: { type: "array", items: { type: "array", items: { type: "number" } } }, + Mixed: { + type: "array", + items: { anyOf: [{ type: "string" }, { type: "array", items: { type: "number" } }] }, + }, + ReadonlyArrayData: { type: "string" }, + }, + }, +}; + +for (const immutable of [false, true]) { + const options = { + immutable, + readWriteMarkers: true, + rootTypes: true, + rootTypesNoSchemaPrefix: true, + transform(schema) { + if (schema.format === "date-time") return "Date"; + }, + postTransform(type) { + assert.equal(typeof type, "string"); + return type; + }, + transformProperty(property) { + if (property.name === "mutable") { + return { ...property, readonly: false, comment: tsComment(["@custom mutable"], property.indent) }; + } + }, + }; + const generated = await openapiTS(schema, options); + assert.equal(typeof generated, "string"); + assert.equal(generated, await cjs.default(schema, options)); + assert(generated.includes("@custom mutable")); + await writeFile(immutable ? "immutable.d.ts" : "mutable.d.ts", generated); +} + +const rootSchema = { + openapi: "3.1.0", + info: schema.info, + $defs: { Token: { type: "string" } }, + components: { schemas: { Token: { $ref: "#/$defs/Token" } } }, +}; +const rootOptions = { + postTransform(type, options) { + return options.path === "#/$defs" + ? '/* definitions */ { readonly [K in "Token"]: string } /* end */' + : type; + }, +}; +const definitions = await openapiTS(rootSchema, rootOptions); +assert.equal(definitions, await cjs.default(rootSchema, rootOptions)); +await writeFile("definitions.d.ts", definitions); + +await writeFile("schema.json", JSON.stringify(schema)); +const cli = join(packageDir, "bin/cli.js"); +execFileSync(process.execPath, [cli, "schema.json", "--immutable", "--read-write-markers", "-o", "cli.d.ts"]); +assert.equal( + await readFile("cli.d.ts", "utf8"), + `${COMMENT_HEADER}${await openapiTS(schema, { immutable: true, readWriteMarkers: true })}`, +); +process.stdout.write("CLI, ESM, CommonJS, and all three hooks passed\n"); diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index 79e5b4571..764b70b8f 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -1,41 +1,83 @@ -import ts from "typescript"; import { addJSDocComment, astToString, BOOLEAN, + INDENT, NULL, NUMBER, oapiRef, + propertySignature, STRING, + tsArray, tsArrayLiteralExpression, tsEnum, + tsEnumMember, + tsIntersection, tsIsPrimitive, tsLiteral, + tsNullable, + tsParenthesize, tsPropertyIndex, tsUnion, + tsWithRequired, + typeLiteral, } from "../../src/lib/ts.js"; +import { expectTypeScriptToCompile } from "../test-helpers.js"; + +describe("astToString", () => { + test("joins generated declarations", () => { + expect(astToString(["type A = string;", "type B = number;"])).toBe("type A = string;\ntype B = number;\n"); + }); + + test("rejects removed printer options with migration guidance", () => { + expect(() => { + // @ts-expect-error printer options were removed with the AST API + astToString("type A = string;", { formatOptions: { removeComments: true } }); + }).toThrow("astToString no longer accepts printer options. Format the returned source separately."); + }); + + test("rejects old AST inputs with migration guidance", () => { + expect(() => { + // @ts-expect-error TypeScript AST nodes were replaced with strings + astToString({ kind: 183 }); + }).toThrow("astToString expects generated source strings; TypeScript AST nodes are no longer supported."); + }); +}); + +/** 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 +89,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 +113,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 +124,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 +166,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 +203,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 +215,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 +223,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 +231,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 +241,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 +258,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 +272,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 +282,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 +301,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 +323,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 +346,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,45 +429,251 @@ 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"); + }); + + test.each([ + ["LF", "\n"], + ["CR", "\r"], + ["CRLF", "\r\n"], + ["line separator", "\u2028"], + ["paragraph separator", "\u2029"], + ])("preserves precedence after a %s line comment", (_name, lineBreak) => { + const element = `string // comment${lineBreak} | number`; + const intersection = tsIntersection([`"one" // comment${lineBreak} | "two"`, '"two"']); + const templateElement = `\`prefix\${"a" // comment${lineBreak}}\` | "other"`; + expect(tsArray(element)).toBe(`(${element})[]`); + expect(tsArray(templateElement)).toBe(`(${templateElement})[]`); + expectTypeScriptToCompile(` + type Elements = ${tsArray(element)}; + const elements: Elements = ["value", 1]; + // @ts-expect-error the entire union describes an array element + const scalar: Elements = "value"; + type Intersection = ${intersection}; + const included: Intersection = "two"; + // @ts-expect-error intersection applies to both union members + const excluded: Intersection = "one"; + type TemplateElements = ${tsArray(templateElement)}; + const templates: TemplateElements = ["prefixa", "other"]; + // @ts-expect-error the template literal is an element, not the whole array + const templateScalar: TemplateElements = "prefixa"; + `); + }); +}); + +describe("tsArray", () => { + const cases = [ + ["string", "string[]"], + ["number[]", "number[][]"], + ["readonly number[]", "(readonly number[])[]"], + ["readonly [string, number]", "(readonly [string, number])[]"], + ["keyof { value: string }", "(keyof { value: string })[]"], + ["typeof value", "(typeof value)[]"], + ["string | number[]", "(string | number[])[]"], + ["{ id: string } & { name: string }", "({ id: string } & { name: string })[]"], + ["(arg: string) => number", "((arg: string) => number)[]"], + ["(arg: U) => U", "((arg: U) => U)[]"], + ["abstract new () => object", "(abstract new () => object)[]"], + ["T extends string ? number : boolean", "(T extends string ? number : boolean)[]"], + ["Record string | number>", "Record string | number>[]"], + // biome-ignore lint/suspicious/noTemplateCurlyInString: Test nested TypeScript template literal types. + ['`${"}" | `${"{"}`}` | number', '(`${"}" | `${"{"}`}` | number)[]'], + ["/* braces { and apostrophe ' */ readonly string[]", "(/* braces { and apostrophe ' */ readonly string[])[]"], + ] as const; + + test.each(cases)("preserves array element precedence for %s", (source, expected) => { + expect(tsArray(source)).toBe(expected); + }); + + test("preserves type semantics for operator and composition elements", () => { + expectTypeScriptToCompile(` + declare const value: { value: string }; + type Equal = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) + ? (() => X extends B ? 1 : 2) extends (() => X extends A ? 1 : 2) ? true : false + : false; + type Assert = T; + ${cases + .map( + ([source, expected], index) => + `type Generated${index} = ${tsArray(source)};\n` + + `type Expected${index} = ${expected};\n` + + `type Check${index} = Assert, Expected${index}>>;`, + ) + .join("\n")} + `); + }); +}); + +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; } | null`); }); }); + +describe("tsWithRequired", () => { + test("does not mistake enum strings for helper declarations", () => { + const footer = [ + tsEnum( + "Decoy", + ["type WithRequired<", "type FlattenedDeepRequired<", "type ReadonlyArray<"], + [{ name: "Required" }, { name: "DeepRequired" }, { name: "Readonly" }], + ).declaration, + ]; + const required = tsWithRequired("{ value?: string }", ["value"], footer); + const values = tsArrayLiteralExpression("values", "string[]", ["value"], { + readonly: true, + injectFooter: footer, + }); + expectTypeScriptToCompile(` + export {}; + ${astToString(footer)} + type Required = ${required}; + const present: Required = { value: "value" }; + // @ts-expect-error the helper must enforce the required key + const absent: Required = {}; + ${values} + const first: string = values[0]; + // @ts-expect-error the enhanced array helper preserves readonly indices + values[0] = "other"; + type Deep = FlattenedDeepRequired<{ items?: { value?: number }[] }>; + const deep: Deep = { items: { value: 1 } }; + // @ts-expect-error nested array elements are flattened and required + const missing: Deep = { items: {} }; + `); + }); + + test("injects the legacy helper once and preserves valid low-level inputs", () => { + const footer: string[] = []; + const source = "Source"; + expect(astToString(tsWithRequired(source, ["value"], footer)).trim()).toBe('WithRequired'); + expect(astToString(tsWithRequired(source, ["other"], footer)).trim()).toBe('WithRequired'); + expect(footer).toHaveLength(1); + const helper = astToString(footer).trim(); + + expect(helper).toBe(`type WithRequired = T & { + [P in K]-?: T[P]; +};`); + expectTypeScriptToCompile(` + ${helper} + + type Optional = WithRequired<{ value?: string }, "value">; + const optional: Optional = { value: "value" }; + // @ts-expect-error implicit optional undefined is removed + const optionalUndefined: Optional = { value: undefined }; + + type ExplicitUndefined = WithRequired<{ value?: string | undefined }, "value">; + const explicitUndefined: ExplicitUndefined = { value: undefined }; + + type ReadonlyValue = WithRequired<{ readonly value?: string }, "value">; + const readonlyValue: ReadonlyValue = { value: "value" }; + // @ts-expect-error readonly is preserved + readonlyValue.value = "other"; + + type Intersection = WithRequired<{ value?: string } & { other: number }, "value">; + const intersection: Intersection = { other: 1, value: "value" }; + + type StringIndex = WithRequired<{ [key: string]: number | undefined }, "value">; + const stringIndex: StringIndex = { value: 1 }; + + type ArrayValue = WithRequired; + const arrayValue: ArrayValue = []; + `); + }); +}); diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index b22f8ad18..377fff8cf 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[] = [ @@ -377,11 +376,7 @@ export type operations = Record;`, options: { transform(schemaObject) { if ("format" in schemaObject && schemaObject.format === "date-time") { - /** - * Tip: use astexplorer.net to first type out the desired TypeScript, - * then use the `typescript` parser and it will tell you the desired - * AST - */ + // Hooks return the desired TypeScript source directly. return DATE; } }, @@ -568,12 +563,8 @@ export type operations = Record;`, options: { postTransform(_type, options) { if (options.path?.includes("Date")) { - /** - * Tip: use astexplorer.net to first type out the desired TypeScript, - * then use the `typescript` parser and it will tell you the desired - * AST - */ - return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("DateOrTime")); + // Hooks return the desired TypeScript source directly. + return "DateOrTime"; } // Previously, in order to access the schema in postTransform, @@ -593,13 +584,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 +676,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/property-hooks.test.ts b/packages/openapi-typescript/test/property-hooks.test.ts new file mode 100644 index 000000000..8446ad539 --- /dev/null +++ b/packages/openapi-typescript/test/property-hooks.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import openapiTS, { tsComment, tsPropertyIndex } from "../src/index.js"; +import type { OpenAPI3, PropertySignatureLike } from "../src/types.js"; +import { expectTypeScriptToCompile } from "./test-helpers.js"; + +describe("transformProperty", () => { + test.each([ + { immutable: false, readWriteMarkers: false }, + { immutable: true, readWriteMarkers: false }, + { immutable: false, readWriteMarkers: true }, + { immutable: true, readWriteMarkers: true }, + ])("controls readonly in properties and $defs with %j", async (options) => { + const properties = { + forcedReadonly: { type: "string" as const }, + forcedMutable: { type: "string" as const, readOnly: true }, + unchanged: { type: "string" as const, readOnly: true }, + }; + const schema: OpenAPI3 = { + openapi: "3.1.0", + info: { title: "Property modifiers", version: "1.0.0" }, + components: { + schemas: { + Example: { type: "object", properties, required: Object.keys(properties), $defs: properties }, + }, + }, + }; + const seen = new Map(); + const generated = await openapiTS(schema, { + ...options, + transformProperty(property, _schema, context) { + seen.set(context.path ?? "", { ...property }); + if (property.name === "forcedReadonly") { + return { ...property, readonly: true }; + } + if (property.name === "forcedMutable") { + return { ...property, readonly: false }; + } + return undefined; + }, + }); + + expect(seen.size).toBe(6); + const markedReadonly = options.immutable || !options.readWriteMarkers; + const markedType = options.readWriteMarkers ? "$Read" : "string"; + for (const prefix of ["#/components/schemas/Example", "#/components/schemas/Example/$defs"]) { + expect(seen.get(`${prefix}/forcedReadonly`)?.readonly).toBe(options.immutable); + expect(seen.get(`${prefix}/forcedMutable`)?.readonly).toBe(markedReadonly); + expect(seen.get(`${prefix}/unchanged`)?.readonly).toBe(markedReadonly); + const indent = seen.get(`${prefix}/unchanged`)?.indent; + expect(generated).toContain(`\n${indent}readonly forcedReadonly: string;`); + expect(generated).toContain(`\n${indent}forcedMutable: ${markedType};`); + expect(generated).toContain(`\n${indent}${markedReadonly ? "readonly " : ""}unchanged: ${markedType};`); + } + }); + + test("preserves name, optionality, type and comments when overriding readonly", async () => { + const field = { type: "string" as const, description: "Schema documentation" }; + const schema: OpenAPI3 = { + openapi: "3.1.0", + info: { title: "Property customization", version: "1.0.0" }, + components: { + schemas: { + Example: { + type: "object", + properties: { original: field }, + required: ["original"], + $defs: { original: field }, + }, + }, + }, + }; + const generated = await openapiTS(schema, { + transformProperty(property) { + return { + ...property, + name: tsPropertyIndex("renamed-value"), + optional: true, + type: "Date | null", + readonly: true, + comment: tsComment(["@custom Hook documentation"], property.indent), + }; + }, + }); + + for (const indent of [" ", " "]) { + expect(generated).toContain( + `${tsComment(["@custom Hook documentation"], indent)}${indent}/** @description Schema documentation */\n${indent}readonly "renamed-value"?: Date | null;`, + ); + } + expectTypeScriptToCompile( + generated, + ` +type Example = components["schemas"]["Example"]; +const empty: Example = { $defs: {} }; +const populated: Example = { "renamed-value": new Date(), $defs: { "renamed-value": null } }; +// @ts-expect-error the renamed property is readonly +populated["renamed-value"] = null; +// @ts-expect-error the renamed definition is readonly +populated.$defs["renamed-value"] = null; +// @ts-expect-error the old name has been replaced +populated.original; +// @ts-expect-error the old definition name has been replaced +populated.$defs.original; +// @ts-expect-error the customized property type rejects strings +const invalid: Example = { "renamed-value": "text", $defs: {} }; +`, + ); + }); +}); diff --git a/packages/openapi-typescript/test/read-write-helpers.test.ts b/packages/openapi-typescript/test/read-write-helpers.test.ts new file mode 100644 index 000000000..218893397 --- /dev/null +++ b/packages/openapi-typescript/test/read-write-helpers.test.ts @@ -0,0 +1,427 @@ +import { resolve } from "node:path"; +import ts from "typescript"; +import { expect, test } from "vitest"; +import openapiTS, { astToString } from "../src/index.js"; +import type { OpenAPI3 } from "../src/types.js"; + +const schema: OpenAPI3 = { + openapi: "3.1.0", + info: { title: "Read/write helper test", version: "1.0.0" }, + components: { + schemas: { + Envelope: { + type: "object", + required: ["id", "secret", "nested"], + properties: { + id: { type: "string", readOnly: true }, + secret: { type: "string", writeOnly: true }, + nested: { + type: "object", + required: ["visible", "readOnlyValue", "writeOnlyValue"], + properties: { + visible: { type: "boolean" }, + readOnlyValue: { type: "number", readOnly: true }, + writeOnlyValue: { type: "number", writeOnly: true }, + }, + }, + }, + }, + Envelopes: { + type: "array", + items: { $ref: "#/components/schemas/Envelope" }, + }, + Pair: { + type: "array", + prefixItems: [{ $ref: "#/components/schemas/Envelope" }, { type: "string" }], + }, + }, + }, +}; + +function getDiagnostics(source: string): readonly ts.Diagnostic[] { + const fileName = resolve("generated-read-write-helpers.ts"); + const options: ts.CompilerOptions = { + lib: ["lib.esnext.d.ts"], + module: ts.ModuleKind.ESNext, + noEmit: true, + skipLibCheck: false, + strict: true, + target: ts.ScriptTarget.ESNext, + types: [], + }; + const sourceFile = ts.createSourceFile(fileName, source, options.target ?? ts.ScriptTarget.ESNext, true); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + + host.getSourceFile = (requestedFileName, languageVersion, onError, shouldCreateNewSourceFile) => + resolve(requestedFileName) === fileName + ? sourceFile + : getSourceFile(requestedFileName, languageVersion, onError, shouldCreateNewSourceFile); + + return ts.getPreEmitDiagnostics(ts.createProgram([fileName], options, host)); +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => "\n", + }); +} + +test("generated read/write helpers do not reserve an additional root type name", async () => { + const generated = astToString( + await openapiTS( + { + openapi: "3.1.0", + info: { title: "Root type collision", version: "1.0.0" }, + components: { schemas: { ReadonlyArrayData: { type: "string" } } }, + }, + { readWriteMarkers: true, rootTypes: true, rootTypesNoSchemaPrefix: true }, + ), + ); + + const assertions = ` +const value: ReadonlyArrayData = "value"; +// @ts-expect-error the root type must retain the schema's constraints +const invalid: ReadonlyArrayData = 123; +`; + expect(formatDiagnostics(getDiagnostics(`${generated}\n${assertions}`))).toBe(""); +}); + +test.each([ + false, + true, +])(`generated read/write helpers preserve callables and collections with immutable: %s (TypeScript ${ts.version})`, async (immutable) => { + const generated = astToString(await openapiTS(schema, { readWriteMarkers: true, immutable })); + + expect(generated).toContain("export type Readable"); + expect(generated).toContain("export type Writable"); + + const readonlyError = immutable ? "// @ts-expect-error immutable collections and properties cannot be mutated" : ""; + const tupleChecks = immutable + ? ` +const responseLength: 2 = responsePair.length; +const requestLength: 2 = requestPair.length; +responsePair[0].id.toUpperCase(); +requestPair[0].secret.toUpperCase(); +responsePair[1].toUpperCase(); +requestPair[1].toUpperCase(); +// @ts-expect-error readonly tuples retain fixed positions +const swappedResponsePair: Readable = ["response", responseValue]; +// @ts-expect-error readonly tuples retain fixed positions +const swappedRequestPair: Writable = ["request", requestValue]; +// @ts-expect-error readonly tuples retain literal length +const shortResponsePair: Readable = [responseValue]; +// @ts-expect-error readonly tuples retain literal length +const longRequestPair: Writable = [requestValue, "request", "extra"]; +` + : ` +// Mutable tuples retain the existing element-union array projection. +const responseElements: (Readable | string)[] = responsePair; +const requestElements: (Writable | string)[] = requestPair; +const legacyResponsePair: Readable = ["response", responseValue, responseValue]; +const legacyRequestPair: Writable = ["request", requestValue, requestValue]; +`; + const assertions = ` +type Envelope = components["schemas"]["Envelope"]; +type Envelopes = components["schemas"]["Envelopes"]; +type Pair = components["schemas"]["Pair"]; + +const responseValue: Readable = { + id: "response-id", + nested: { visible: true, readOnlyValue: 1 }, +}; +const requestValue: Writable = { + secret: "request-secret", + nested: { visible: true, writeOnlyValue: 1 }, +}; + +declare const response: Readable; +response.id.toUpperCase(); +response.nested.visible.valueOf(); +response.nested.readOnlyValue.toFixed(); +// @ts-expect-error write-only properties are excluded from responses +response.secret; +// @ts-expect-error nested write-only properties are excluded from responses +response.nested.writeOnlyValue; + +declare const request: Writable; +request.secret.toUpperCase(); +request.nested.visible.valueOf(); +request.nested.writeOnlyValue.toFixed(); +// @ts-expect-error read-only properties are forbidden in requests +request.id.toUpperCase(); +// @ts-expect-error nested read-only properties are forbidden in requests +request.nested.readOnlyValue.toFixed(); + +const responses: Readable = [responseValue]; +const requests: Writable = [requestValue]; +const responseCopy: Readable[] = Array.from(responses); +const requestCopy: Writable[] = Array.from(requests); + +// Interfaces and intersections must expose the same resolved elements through methods and indices. +interface ArrayInterface extends Array {} +interface ReadonlyArrayInterface extends ReadonlyArray {} +type AugmentedArray = Envelope[] & { readonly brand?: string }; +type AugmentedReadonlyArray = readonly Envelope[] & { readonly brand?: string }; +const responseCollections: { + arrayInterface: Readable; + readonlyInterface: Readable; + augmentedArray: Readable; + augmentedReadonlyArray: Readable; +} = { + arrayInterface: [responseValue], + readonlyInterface: [responseValue], + augmentedArray: [responseValue], + augmentedReadonlyArray: [responseValue], +}; +const requestCollections: { + arrayInterface: Writable; + readonlyInterface: Writable; + augmentedArray: Writable; + augmentedReadonlyArray: Writable; +} = { + arrayInterface: [requestValue], + readonlyInterface: [requestValue], + augmentedArray: [requestValue], + augmentedReadonlyArray: [requestValue], +}; +requestCollections.arrayInterface.push(requestValue); +requestCollections.augmentedArray.push(requestValue); +// @ts-expect-error array subtype methods must reject read-only properties +requestCollections.arrayInterface.push({ ...requestValue, id: "forbidden" }); +// @ts-expect-error augmented array methods must reject read-only properties +requestCollections.augmentedArray.push({ ...requestValue, id: "forbidden" }); +for (const entries of Object.values(responseCollections)) { + const indexed: string = entries[0].id; + const mapped: string[] = entries.map((entry) => entry.id); + const copy: Readable[] = Array.from(entries); +} +for (const entries of Object.values(requestCollections)) { + const indexed: string = entries[0].secret; + const mapped: string[] = entries.map((entry) => entry.secret); + const copy: Writable[] = Array.from(entries); +} +responses.map((entry) => { + entry.id.toUpperCase(); + entry.nested.readOnlyValue.toFixed(); + // @ts-expect-error callbacks must not expose write-only properties + entry.secret; + // @ts-expect-error callbacks must not expose nested write-only properties + entry.nested.writeOnlyValue; +}); +requests.map((entry) => { + entry.secret.toUpperCase(); + entry.nested.writeOnlyValue.toFixed(); + // @ts-expect-error callbacks must forbid read-only properties + entry.id.toUpperCase(); + // @ts-expect-error callbacks must forbid nested read-only properties + entry.nested.readOnlyValue.toFixed(); +}); +for (const entry of responses) { + entry.id.toUpperCase(); + // @ts-expect-error iterators must not expose write-only properties + entry.secret; +} +for (const entry of requests.values()) { + entry.secret.toUpperCase(); + // @ts-expect-error iterators must forbid read-only properties + entry.id.toUpperCase(); +} +${readonlyError} +responses.push(responseValue); +${readonlyError} +requests.push(requestValue); +${readonlyError} +responses[0] = responseValue; +${readonlyError} +requests[0] = requestValue; +${readonlyError} +responses[0].nested.readOnlyValue = 2; +${readonlyError} +requests[0].nested.writeOnlyValue = 2; +${readonlyError} +const mutableResponses: Readable[] = responses; +${readonlyError} +const mutableRequests: Writable[] = requests; + +const responsePair: Readable = [responseValue, "response"]; +const requestPair: Writable = [requestValue, "request"]; +${tupleChecks} +${readonlyError} +responsePair[1] = "updated"; +${readonlyError} +requestPair[1] = "updated"; +${readonlyError} +const mutableResponsePair: (Readable | string)[] = responsePair; +${readonlyError} +const mutableRequestPair: (Writable | string)[] = requestPair; + +type GenericHandler = (value: T) => { value: T }; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; +type ReadableAny = Assert, any>>; +type WritableAny = Assert, any>>; +type ReadableUnknown = Assert, unknown>>; +type WritableUnknown = Assert, unknown>>; +function readableGeneric[]>(value: Readable) { + const first: string = value[0]; + // @ts-expect-error constrained generics resolve markers without widening to any + const _invalid: number = value[0]; + return first; +} +function writableGeneric[]>(value: Writable) { + const first: string = value[0]; + // @ts-expect-error constrained generics resolve markers without widening to any + const _invalid: number = value[0]; + return first; +} +function readableGenericObject }>(value: Readable) { + const name: string = value.name; + // @ts-expect-error object constraints resolve markers without widening to any + const _invalid: number = value.name; + return name; +} +function writableGenericObject }>(value: Writable) { + const name: string = value.name; + // @ts-expect-error object constraints resolve markers without widening to any + const _invalid: number = value.name; + return name; +} +type ReadableNonEmptyArray = readonly $Read[] & { readonly 0: $Read<"first"> }; +type WritableNonEmptyArray = readonly $Write[] & { readonly 0: $Write<"first"> }; +type ReadableNumericProperty = Assert[0], "first">>; +type WritableNumericProperty = Assert[0], "first">>; +const readableNonEmptyArray: Readable = ["first"]; +const writableNonEmptyArray: Writable = ["first"]; +// @ts-expect-error required numeric properties must not disappear with the broad array index +const readableEmptyArray: Readable = []; +// @ts-expect-error required numeric properties must not disappear with the broad array index +const writableEmptyArray: Writable = []; +// @ts-expect-error narrowed numeric properties must retain their value constraints +const readableWrongFirst: Readable = ["other"]; +// @ts-expect-error narrowed numeric properties must retain their value constraints +const writableWrongFirst: Writable = ["other"]; +type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; +type NestedArray = (string | NestedArray)[]; +const readableJson: Readable = ["one", { two: [2] }]; +const writableJson: Writable = ["one", { two: [2] }]; +const readableNestedArray: Readable = ["one", ["two"]]; +const writableNestedArray: Writable = ["one", ["two"]]; +type RecursiveTuple = [string | RecursiveTuple]; +type RecursiveOptionalTuple = [string, RecursiveOptionalTuple?]; +type RecursiveRestTuple = [string, ...RecursiveRestTuple[]]; +type RecursiveReadonlyTuple = readonly [string | RecursiveReadonlyTuple]; +type RecursiveReadonlyOptionalTuple = readonly [string, RecursiveReadonlyOptionalTuple?]; +type RecursiveReadonlyRestTuple = readonly [string, ...RecursiveReadonlyRestTuple[]]; +const readableRecursiveTuple: Readable = [[["leaf"]]]; +const writableRecursiveTuple: Writable = [[["leaf"]]]; +const readableRecursiveOptional: Readable = ["a", ["b"]]; +const writableRecursiveOptional: Writable = ["a", ["b"]]; +const readableRecursiveRest: Readable = ["a", ["b"]]; +const writableRecursiveRest: Writable = ["a", ["b"]]; +const readableRecursiveReadonlyTuple: Readable = [[["leaf"]]]; +const writableRecursiveReadonlyTuple: Writable = [[["leaf"]]]; +const readableRecursiveReadonlyOptional: Readable = ["a", ["b"]]; +const writableRecursiveReadonlyOptional: Writable = ["a", ["b"]]; +const readableRecursiveReadonlyRest: Readable = ["a", ["b"]]; +const writableRecursiveReadonlyRest: Writable = ["a", ["b"]]; +// @ts-expect-error recursive mutable tuples still reject invalid leaves +const invalidReadableRecursiveTuple: Readable = [[123]]; +// @ts-expect-error recursive readonly tuples still reject invalid leaves +const invalidWritableRecursiveTuple: Writable = [[true]]; +// @ts-expect-error excluded elements are never, consistently with mutable arrays +const excludedReadonlyElement: Writable]> = [undefined]; + +type MarkedReadonlyArray = readonly Envelope[] & { id: $Read; secret: $Write }; +declare const readableArrayMetadata: Readable; +declare const writableArrayMetadata: Writable; +readableArrayMetadata.id.toFixed(); +writableArrayMetadata.secret.toUpperCase(); +// @ts-expect-error write-only array metadata is excluded from responses +readableArrayMetadata.secret; +// @ts-expect-error read-only array metadata is forbidden in requests +writableArrayMetadata.id.toFixed(); +type ReadableHandlerIsExact = Assert, GenericHandler>>; +type WritableHandlerIsExact = Assert, GenericHandler>>; +type Domain = { + createdAt: Date; + pattern: RegExp; + handler: GenericHandler; + nested: { + readDate: $Read; + writePattern: $Write; + handlers: GenericHandler[]; + }; +}; + +declare const readableDomain: Readable; +readableDomain.createdAt.toISOString(); +readableDomain.pattern.test("value"); +const readableLiteral: "value" = readableDomain.handler("value").value; +// @ts-expect-error generic callables retain their parameter constraints +readableDomain.handler(true); +// @ts-expect-error generic callables retain their return types +const wrongReadableResult: number = readableDomain.handler("value").value; +// @ts-expect-error Date methods retain their parameter lists +readableDomain.createdAt.toISOString(1); +// @ts-expect-error Date methods retain their return types +const wrongReadableDate: number = readableDomain.createdAt.toISOString(); +// @ts-expect-error RegExp methods retain their parameter types +readableDomain.pattern.test(1); +readableDomain.nested.readDate.getTime(); +readableDomain.nested.handlers[0](1).value.toFixed(); +// @ts-expect-error nested write markers are still filtered +readableDomain.nested.writePattern; + +declare const writableDomain: Writable; +writableDomain.createdAt.toISOString(); +writableDomain.pattern.exec("value"); +const writableLiteral: 42 = writableDomain.handler(42).value; +// @ts-expect-error generic callables retain their parameter constraints +writableDomain.handler(true); +// @ts-expect-error generic callables retain their return types +const wrongWritableResult: string = writableDomain.handler(42).value; +// @ts-expect-error Date methods retain their parameter lists +writableDomain.createdAt.toISOString(1); +// @ts-expect-error Date methods retain their return types +const wrongWritableDate: number = writableDomain.createdAt.toISOString(); +// @ts-expect-error RegExp methods retain their parameter types +writableDomain.pattern.exec(1); +writableDomain.nested.writePattern.test("value"); +writableDomain.nested.handlers[0](1).value.toFixed(); +// @ts-expect-error nested read markers are still forbidden +writableDomain.nested.readDate.getTime(); + +type AugmentedDate = Date & { + responseId: $Read; + requestSecret: $Write; +}; + +declare const readableDate: Readable; +readableDate.toISOString(); +readableDate.responseId.toFixed(); +// @ts-expect-error an augmented Date's write marker must not bypass filtering +readableDate.requestSecret; + +declare const writableDate: Writable; +writableDate.toISOString(); +writableDate.requestSecret.toUpperCase(); +// @ts-expect-error an augmented Date's read marker must not bypass filtering +writableDate.responseId.toFixed(); + +// Callables are opaque leaves, including any properties attached to them. +type OpaqueHandler = GenericHandler & { responseId: $Read; requestSecret: $Write }; +type ReadableCallableIsExact = Assert, OpaqueHandler>>; +type WritableCallableIsExact = Assert, OpaqueHandler>>; +declare const readableCallable: Readable; +declare const writableCallable: Writable; +readableCallable.responseId.$read.toFixed(); +readableCallable.requestSecret.$write.toUpperCase(); +writableCallable.responseId.$read.toFixed(); +writableCallable.requestSecret.$write.toUpperCase(); +`; + + expect(formatDiagnostics(getDiagnostics(`${generated}\n${assertions}`))).toBe(""); +}); diff --git a/packages/openapi-typescript/test/root-definitions.test.ts b/packages/openapi-typescript/test/root-definitions.test.ts new file mode 100644 index 000000000..bfbdda10b --- /dev/null +++ b/packages/openapi-typescript/test/root-definitions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "vitest"; +import openapiTS from "../src/index.js"; +import { expectTypeScriptToCompile } from "./test-helpers.js"; + +const schema = JSON.stringify({ + openapi: "3.1.0", + info: { title: "Root definitions", version: "1.0.0" }, + $defs: { Foo: { type: "string" } }, + components: { schemas: { Foo: { $ref: "#/$defs/Foo" } } }, +}); + +describe("root definition hook output", () => { + test.each([ + "{ Foo: string }", + " \n\t{ Foo: string }\t\n ", + "{\n Foo: string;\n}", + '{ Foo: string; nested?: { value: "}" | "{"; /* } & { */ }; }', + "/* definitions */ { Foo: string }", + "{ Foo: string } /* definitions */", + "// definitions\n{ Foo: string } // definitions", + // biome-ignore lint/suspicious/noTemplateCurlyInString: This is TypeScript source containing a template literal type. + '{ Foo: string; comments?: "/*" | "*/" | "//"; template?: `/*${string}*/`; }', + ])("preserves object members independently of formatting: %s", async (replacement) => { + const generated = await openapiTS(schema, { + postTransform: (_type, options) => (options.path === "#/$defs" ? replacement : undefined), + }); + + expectTypeScriptToCompile( + generated, + ` +const valid: components["schemas"]["Foo"] = "value"; +// @ts-expect-error the root definition still describes a string +const invalid: components["schemas"]["Foo"] = 1; +`, + ); + }); + + test.each([ + "{}", + " { \n\t } ", + "{ /* no definitions */ }", + "/* before */ { // no definitions\n } /* after */", + "{\n Foo: string;\n} & {\n Bar: number;\n}", + "{\n Foo: string;\n} | {\n Bar: number;\n}", + "{\n Foo: string;\n} extends object ? { Foo: number } : {\n Bar: number;\n}", + ])("retains the empty fallback for nonmember roots: %s", async (replacement) => { + const generated = await openapiTS(schema, { + postTransform: (_type, options) => (options.path === "#/$defs" ? replacement : undefined), + }); + + expect(generated).toContain("export type $defs = Record;"); + expectTypeScriptToCompile( + generated, + ` +// @ts-expect-error a skipped root definition has no usable values +const invalid: components["schemas"]["Foo"] = "value"; +`, + ); + }); + + test.each([false, true])("uses a valid alias for mapped root types with exportType: %s", async (exportType) => { + const generated = await openapiTS(schema, { + exportType, + postTransform: (_type, options) => (options.path === "#/$defs" ? '{ readonly [K in "Foo"]: string }' : undefined), + }); + expect(generated).toContain("export type $defs ="); + expectTypeScriptToCompile( + generated, + ` +const valid: components["schemas"]["Foo"] = "value"; +// @ts-expect-error mapped definitions retain their value type +const invalid: components["schemas"]["Foo"] = 1; +declare const definitions: $defs; +// @ts-expect-error mapped definitions retain readonly keys +definitions.Foo = "other"; +`, + ); + }); +}); diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index f7008bbff..7ddd9a430 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -1,4 +1,5 @@ import { createConfig } from "@redocly/openapi-core"; +import ts from "typescript"; import { resolveRef } from "../src/lib/utils.js"; import type { GlobalContext, TransformNodeOptions } from "../src/types.js"; @@ -63,3 +64,30 @@ export type TestCase = [ ci?: { timeout?: number; skipIf?: boolean }; }, ]; + +/** Compile generated TypeScript with the package's strict semantic expectations. */ +export function expectTypeScriptToCompile(source: string, extraSource = "") { + const fileName = "/generated.test.ts"; + const contents = `${source}\n${extraSource}`; + const compilerOptions: ts.CompilerOptions = { + exactOptionalPropertyTypes: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + }; + const host = ts.createCompilerHost(compilerOptions); + const sourceFile = ts.createSourceFile(fileName, contents, ts.ScriptTarget.ESNext, true); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => + name === fileName ? sourceFile : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + const fileExists = host.fileExists.bind(host); + host.fileExists = (name) => name === fileName || fileExists(name); + const readFile = host.readFile.bind(host); + host.readFile = (name) => (name === fileName ? contents : readFile(name)); + + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram([fileName], compilerOptions, host)); + expect(diagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))).toEqual([]); +} 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..d4867e929 100644 --- a/packages/openapi-typescript/test/transform/paths-object.test.ts +++ b/packages/openapi-typescript/test/transform/paths-object.test.ts @@ -2,11 +2,33 @@ import { fileURLToPath } from "node:url"; import { astToString } from "../../src/lib/ts.js"; import transformPathsObject from "../../src/transform/paths-object.js"; import type { GlobalContext } from "../../src/types.js"; -import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; +import { DEFAULT_CTX, expectTypeScriptToCompile, type TestCase } from "../test-helpers.js"; const DEFAULT_OPTIONS = DEFAULT_CTX; describe("transformPathsObject", () => { + test.each([ + ["my`id", "/path"], + ["my\\id", "/path"], + ["id", "/a\\b`c"], + ])("preserves escaped path template names and literals: %s, %s", (name, prefix) => { + const result = transformPathsObject( + { + [`${prefix}/{${name}}`]: { + parameters: [{ name, in: "path", required: true, schema: { type: "number" } }], + get: { responses: { 200: { description: "OK" } } }, + }, + }, + { ...DEFAULT_OPTIONS, pathParamsAsTypes: true, injectFooter: [] }, + ); + expectTypeScriptToCompile(` + type Paths = ${result}; + type ValidPath = Paths[${JSON.stringify(`${prefix}/42`)}]; + // @ts-expect-error the placeholder remains numeric after escaping + type InvalidPath = Paths[${JSON.stringify(`${prefix}/text`)}]; + `); + }); + const tests: TestCase[] = [ [ "basic", @@ -371,6 +393,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 }, }, diff --git a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts index 9de603479..eb1f5c2f0 100644 --- a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -240,12 +240,12 @@ export type operations = Record;`, ]; describe("transformComponentsObject", () => { - describe.each(tests)("Case: %s", (name, { given, want, options, ci }) => { + describe.each(tests)("Case: %s", (_name, { given, want, options, ci }) => { test.skipIf(ci?.skipIf)( "it matches the snapshot", async () => { assert(typeof want === "string"); - const result = astToString(transformSchema(given, options?.ctx ?? DEFAULT_CTX), { fileName: name }); + const result = astToString(transformSchema(given, options?.ctx ?? DEFAULT_CTX)); expect(result.trim()).toBe(want.trim()); }, ci?.timeout, diff --git a/packages/openapi-typescript/tsconfig.json b/packages/openapi-typescript/tsconfig.json index 96c4e58bb..383428173 100644 --- a/packages/openapi-typescript/tsconfig.json +++ b/packages/openapi-typescript/tsconfig.json @@ -7,5 +7,5 @@ "types": ["vitest/globals"] }, "include": ["scripts", "src", "test", "*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "test/fixtures/consumer"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d3b87bc5..213cdc995 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ catalogs: specifier: ^9.6.1 version: 9.6.1 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 vite: specifier: ^7.3.1 version: 7.3.1 @@ -52,13 +52,13 @@ importers: version: 2.9.9 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 unbuild: specifier: 3.6.1 - version: 3.6.1(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3)) + version: 3.6.1(typescript@6.0.3)(vue-tsc@2.2.12(typescript@6.0.3))(vue@3.5.27(typescript@6.0.3)) vitest: specifier: 4.1.5 - version: 4.1.5(@types/node@25.6.0)(jsdom@20.0.3)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) + version: 4.1.5(@types/node@25.6.0)(jsdom@20.0.3)(msw@2.14.3(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) docs: devDependencies: @@ -67,7 +67,7 @@ importers: version: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) vitepress: specifier: 1.6.4 - version: 1.6.4(@algolia/client-search@5.48.0)(@types/node@25.6.0)(@types/react@18.3.28)(axios@1.16.0)(change-case@5.4.4)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.13.0)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.48.0)(@types/node@25.6.0)(@types/react@18.3.28)(axios@1.16.0)(change-case@5.4.4)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.13.0)(typescript@6.0.3) packages/openapi-fetch: dependencies: @@ -104,7 +104,7 @@ importers: version: 10.3.0 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 undici: specifier: 7.25.0 version: 7.25.0 @@ -138,16 +138,16 @@ importers: version: link:../../../openapi-typescript typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 packages/openapi-fetch/examples/sveltekit: devDependencies: '@sveltejs/adapter-auto': specifier: ^6.1.1 - version: 6.1.1(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))) + version: 6.1.1(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))) '@sveltejs/kit': specifier: ^2.52.2 - version: 2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) + version: 2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 version: 5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) @@ -162,10 +162,10 @@ importers: version: 5.53.5 svelte-check: specifier: ^4.3.6 - version: 4.3.6(picomatch@4.0.4)(svelte@5.53.5)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.4)(svelte@5.53.5)(typescript@6.0.3) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 vite: specifier: 'catalog:' version: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) @@ -177,14 +177,14 @@ importers: version: link:../.. vue: specifier: ^3.5.27 - version: 3.5.27(typescript@5.9.3) + version: 3.5.27(typescript@6.0.3) devDependencies: '@tsconfig/node20': specifier: ^20.1.9 version: 20.1.9 '@vitejs/plugin-vue': specifier: ^5.2.4 - version: 5.2.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + version: 5.2.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.27(typescript@6.0.3)) '@vue/tsconfig': specifier: ^0.5.1 version: 0.5.1 @@ -193,13 +193,13 @@ importers: version: link:../../../openapi-typescript typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 vite: specifier: 'catalog:' version: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) vue-tsc: specifier: ^2.2.12 - version: 2.2.12(typescript@5.9.3) + version: 2.2.12(typescript@6.0.3) packages/openapi-react-query: dependencies: @@ -224,7 +224,7 @@ importers: version: 9.6.1 msw: specifier: 2.14.3 - version: 2.14.3(@types/node@25.6.0)(typescript@5.9.3) + version: 2.14.3(@types/node@25.6.0)(typescript@6.0.3) openapi-fetch: specifier: workspace:^ version: link:../openapi-fetch @@ -243,12 +243,18 @@ importers: '@redocly/openapi-core': specifier: ^1.34.6 version: 1.34.6(supports-color@10.2.2) + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 ansi-colors: specifier: ^4.1.3 version: 4.1.3 change-case: specifier: ^5.4.4 version: 5.4.4 + json-schema-to-ts: + specifier: 3.1.1 + version: 3.1.1 parse-json: specifier: ^8.3.0 version: 8.3.0 @@ -265,9 +271,6 @@ importers: '@types/degit': specifier: 2.8.6 version: 2.8.6 - '@types/js-yaml': - specifier: 4.0.9 - version: 4.0.9 degit: specifier: 2.8.4 version: 2.8.4 @@ -279,7 +282,7 @@ importers: version: 7.2.0 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 vite-node: specifier: 5.3.0 version: 5.3.0(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) @@ -287,8 +290,8 @@ importers: packages/openapi-typescript-helpers: devDependencies: typescript: - specifier: 5.9.3 - version: 5.9.3 + specifier: 'catalog:' + version: 6.0.3 packages: @@ -540,9 +543,11 @@ packages: '@blgc/types@0.0.22': resolution: {integrity: sha512-BCW/N9/Z0KpakL9iT2hi49U//lAQGWU5GcK724BZ1NdmjQsXsJQfimCOOcrvjEuSY+mBUexpzx3ngXBmMJOnUg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@blgc/utils@0.0.62': resolution: {integrity: sha512-5hLMykKIrZs2FoGW/vE50A5btgM5szJF/nWmPbA6io0wKAHYusvksAB+hh4YkudjX+VBlC7k/Ii3SgXw+RjjLQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -2029,6 +2034,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} @@ -3147,6 +3153,10 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -4222,6 +4232,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4249,8 +4262,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -5869,11 +5882,11 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-auto@6.1.1(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))': + '@sveltejs/adapter-auto@6.1.1(@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) - '@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))': + '@sveltejs/kit@2.52.2(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) @@ -5891,7 +5904,7 @@ snapshots: svelte: 5.53.5 vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))': dependencies: @@ -6082,15 +6095,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.27(typescript@6.0.3))': dependencies: vite: 5.4.21(@types/node@25.6.0) - vue: 3.5.27(typescript@5.9.3) + vue: 3.5.27(typescript@6.0.3) - '@vitejs/plugin-vue@5.2.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.27(typescript@6.0.3))': dependencies: vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) - vue: 3.5.27(typescript@5.9.3) + vue: 3.5.27(typescript@6.0.3) '@vitest/expect@4.1.5': dependencies: @@ -6101,13 +6114,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3) + msw: 2.14.3(@types/node@25.6.0)(typescript@6.0.3) vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) '@vitest/pretty-format@4.1.5': @@ -6199,7 +6212,7 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/language-core@2.2.12(typescript@5.9.3)': + '@vue/language-core@2.2.12(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.15 '@vue/compiler-dom': 3.5.27 @@ -6210,7 +6223,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 '@vue/reactivity@3.5.27': dependencies: @@ -6228,30 +6241,30 @@ snapshots: '@vue/shared': 3.5.27 csstype: 3.2.3 - '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3))': + '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@6.0.3))': dependencies: '@vue/compiler-ssr': 3.5.27 '@vue/shared': 3.5.27 - vue: 3.5.27(typescript@5.9.3) + vue: 3.5.27(typescript@6.0.3) '@vue/shared@3.5.27': {} '@vue/tsconfig@0.5.1': {} - '@vueuse/core@12.8.2(typescript@5.9.3)': + '@vueuse/core@12.8.2(typescript@6.0.3)': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 12.8.2 - '@vueuse/shared': 12.8.2(typescript@5.9.3) - vue: 3.5.27(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.27(typescript@6.0.3) transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(axios@1.16.0)(change-case@5.4.4)(focus-trap@7.8.0)(typescript@5.9.3)': + '@vueuse/integrations@12.8.2(axios@1.16.0)(change-case@5.4.4)(focus-trap@7.8.0)(typescript@6.0.3)': dependencies: - '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/shared': 12.8.2(typescript@5.9.3) - vue: 3.5.27(typescript@5.9.3) + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.27(typescript@6.0.3) optionalDependencies: axios: 1.16.0 change-case: 5.4.4 @@ -6261,9 +6274,9 @@ snapshots: '@vueuse/metadata@12.8.2': {} - '@vueuse/shared@12.8.2(typescript@5.9.3)': + '@vueuse/shared@12.8.2(typescript@6.0.3)': dependencies: - vue: 3.5.27(typescript@5.9.3) + vue: 3.5.27(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -7366,6 +7379,11 @@ snapshots: jsesc@3.1.0: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json5@2.2.3: {} @@ -7505,7 +7523,7 @@ snapshots: mitt@3.0.1: {} - mkdist@2.4.1(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3)): + mkdist@2.4.1(typescript@6.0.3)(vue-tsc@2.2.12(typescript@6.0.3))(vue@3.5.27(typescript@6.0.3)): dependencies: autoprefixer: 10.4.24(postcss@8.5.6) citty: 0.1.6 @@ -7521,9 +7539,9 @@ snapshots: semver: 7.7.4 tinyglobby: 0.2.16 optionalDependencies: - typescript: 5.9.3 - vue: 3.5.27(typescript@5.9.3) - vue-tsc: 2.2.12(typescript@5.9.3) + typescript: 6.0.3 + vue: 3.5.27(typescript@6.0.3) + vue-tsc: 2.2.12(typescript@6.0.3) mlly@1.8.0: dependencies: @@ -7538,7 +7556,7 @@ snapshots: ms@2.1.3: {} - msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3): + msw@2.14.3(@types/node@25.6.0)(typescript@6.0.3): dependencies: '@inquirer/confirm': 6.0.11(@types/node@25.6.0) '@mswjs/interceptors': 0.41.3 @@ -7559,7 +7577,7 @@ snapshots: until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@types/node' @@ -8054,11 +8072,11 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-dts@6.3.0(rollup@4.57.1)(typescript@5.9.3): + rollup-plugin-dts@6.3.0(rollup@4.57.1)(typescript@6.0.3): dependencies: magic-string: 0.30.21 rollup: 4.57.1 - typescript: 5.9.3 + typescript: 6.0.3 optionalDependencies: '@babel/code-frame': 7.29.0 @@ -8352,7 +8370,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.6(picomatch@4.0.4)(svelte@5.53.5)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.4)(svelte@5.53.5)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -8360,7 +8378,7 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.53.5 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - picomatch @@ -8461,6 +8479,8 @@ snapshots: trim-lines@3.0.1: {} + ts-algebra@2.0.0: {} + tslib@2.8.1: {} tuple-result@0.0.12: {} @@ -8488,14 +8508,14 @@ snapshots: typescript@5.6.1-rc: {} - typescript@5.9.3: {} + typescript@6.0.3: {} ufo@1.6.3: {} uglify-js@3.19.3: optional: true - unbuild@3.6.1(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3)): + unbuild@3.6.1(typescript@6.0.3)(vue-tsc@2.2.12(typescript@6.0.3))(vue@3.5.27(typescript@6.0.3)): dependencies: '@rollup/plugin-alias': 5.1.1(rollup@4.57.1) '@rollup/plugin-commonjs': 28.0.9(rollup@4.57.1) @@ -8511,18 +8531,18 @@ snapshots: hookable: 5.5.3 jiti: 2.6.1 magic-string: 0.30.21 - mkdist: 2.4.1(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3)) + mkdist: 2.4.1(typescript@6.0.3)(vue-tsc@2.2.12(typescript@6.0.3))(vue@3.5.27(typescript@6.0.3)) mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 pretty-bytes: 7.1.0 rollup: 4.57.1 - rollup-plugin-dts: 6.3.0(rollup@4.57.1)(typescript@5.9.3) + rollup-plugin-dts: 6.3.0(rollup@4.57.1)(typescript@6.0.3) scule: 1.3.0 tinyglobby: 0.2.15 untyped: 2.0.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - sass - vue @@ -8654,7 +8674,7 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2) - vitepress@1.6.4(@algolia/client-search@5.48.0)(@types/node@25.6.0)(@types/react@18.3.28)(axios@1.16.0)(change-case@5.4.4)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.13.0)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.48.0)(@types/node@25.6.0)(@types/react@18.3.28)(axios@1.16.0)(change-case@5.4.4)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.13.0)(typescript@6.0.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.48.0)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.13.0) @@ -8663,17 +8683,17 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.27(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.27(typescript@6.0.3)) '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.27 - '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/integrations': 12.8.2(axios@1.16.0)(change-case@5.4.4)(focus-trap@7.8.0)(typescript@5.9.3) + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(axios@1.16.0)(change-case@5.4.4)(focus-trap@7.8.0)(typescript@6.0.3) focus-trap: 7.8.0 mark.js: 8.11.1 minisearch: 7.2.0 shiki: 2.5.0 vite: 5.4.21(@types/node@25.6.0) - vue: 3.5.27(typescript@5.9.3) + vue: 3.5.27(typescript@6.0.3) optionalDependencies: postcss: 8.5.6 transitivePeerDependencies: @@ -8703,10 +8723,10 @@ snapshots: - typescript - universal-cookie - vitest@4.1.5(@types/node@25.6.0)(jsdom@20.0.3)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)): + vitest@4.1.5(@types/node@25.6.0)(jsdom@20.0.3)(msw@2.14.3(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -8733,21 +8753,21 @@ snapshots: vscode-uri@3.1.0: {} - vue-tsc@2.2.12(typescript@5.9.3): + vue-tsc@2.2.12(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.15 - '@vue/language-core': 2.2.12(typescript@5.9.3) - typescript: 5.9.3 + '@vue/language-core': 2.2.12(typescript@6.0.3) + typescript: 6.0.3 - vue@3.5.27(typescript@5.9.3): + vue@3.5.27(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.27 '@vue/compiler-sfc': 3.5.27 '@vue/runtime-dom': 3.5.27 - '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@5.9.3)) + '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@6.0.3)) '@vue/shared': 3.5.27 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 w3c-xmlserializer@4.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85eba97d8..96217e613 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ packages: catalog: execa: ^9.6.1 - typescript: ^5.9.3 + typescript: ^6.0.3 vite: ^7.3.1 ignoredBuiltDependencies: @@ -16,3 +16,10 @@ ignoredBuiltDependencies: onlyBuiltDependencies: - '@biomejs/biome' + +peerDependencyRules: + # These versions pass the TypeScript 6 build and example checks. + allowedVersions: + '@sveltejs/kit@2.52.2>typescript': 6.0.3 + 'unbuild@3.6.1>typescript': 6.0.3 + 'rollup-plugin-dts@6.3.0>typescript': 6.0.3