diff --git a/packages/openapi-code-generator/package.json b/packages/openapi-code-generator/package.json index b4c32cc26..aff249461 100644 --- a/packages/openapi-code-generator/package.json +++ b/packages/openapi-code-generator/package.json @@ -58,7 +58,6 @@ "@typespec/versioning": "0.77.0", "@typespec/xml": "0.77.0", "joi": "^18.0.2", - "piscina": "^5.1.4", "tsx": "^4.21.0" }, "dependencies": { diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 2f3bc2ae2..bf989ba64 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -60,7 +60,11 @@ export type InputConfig = { enumExtensibility: "open" | "closed" } -export class Input { +export interface ISchemaProvider { + schema(maybeRef: MaybeIRModel): IRModel +} + +export class Input implements ISchemaProvider { constructor( private loader: OpenapiLoader, readonly config: InputConfig, diff --git a/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts b/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts index 3c5fca7d8..c15b08495 100644 --- a/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts +++ b/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts @@ -4,11 +4,13 @@ import type { IRModelBoolean, IRModelIntersection, IRModelNever, + IRModelNull, IRModelNumeric, IRModelObject, IRModelRecord, IRModelString, IRModelUnion, + IRRef, } from "../core/openapi-types-normalized" const base = { @@ -110,9 +112,20 @@ const extension = { default: undefined, "x-internal-preprocess": undefined, } satisfies IRModelUnion, + null: { + isIRModel: true, + type: "null", + nullable: false, + } satisfies IRModelNull, } export const irFixture = { + ref(path: string, file = ""): IRRef { + return { + $ref: `${file}#${path}`, + "x-internal-preprocess": undefined, + } + }, any(partial: Partial = {}): IRModelAny { return {...base.any, ...partial} }, @@ -147,4 +160,7 @@ export const irFixture = { union(partial: Partial = {}): IRModelUnion { return {...extension.union, ...partial} }, + null(partial: Partial = {}): IRModelNull { + return {...extension.null, ...partial} + }, } diff --git a/packages/openapi-code-generator/src/test/typescript-compiler-worker-pool.test-utils.ts b/packages/openapi-code-generator/src/test/typescript-compiler-worker-pool.test-utils.ts deleted file mode 100644 index b1c76f080..000000000 --- a/packages/openapi-code-generator/src/test/typescript-compiler-worker-pool.test-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import path from "node:path" -import {Piscina} from "piscina" -import {filename} from "./typescript-compiler-worker.test-utils" - -let pool: Piscina | null = null - -export function startWorkerPool() { - if (pool) { - throw new Error("already started") - } - - pool = new Piscina({ - filename: path.resolve(__dirname, "./workerWrapper.js"), - workerData: {fullpath: filename}, - concurrentTasksPerWorker: 1, - maxThreads: 4, - minThreads: 4, - idleTimeout: 5_000, - }) -} - -export function stopWorkerPool() { - if (!pool) { - throw new Error("worker pool not started") - } - - pool.destroy() - pool = null -} - -export async function typecheckInWorker( - compilationUnits: {filename: string; content: string}[], -) { - if (!pool) { - throw new Error("worker pool not started") - } - - await pool.run(compilationUnits) -} diff --git a/packages/openapi-code-generator/src/test/typescript-compiler-worker.test-utils.ts b/packages/openapi-code-generator/src/test/typescript-compiler-worker.test-utils.ts deleted file mode 100644 index 502e15260..000000000 --- a/packages/openapi-code-generator/src/test/typescript-compiler-worker.test-utils.ts +++ /dev/null @@ -1,87 +0,0 @@ -import fs from "node:fs" -import path from "node:path" -import ts from "typescript" -import {TypescriptFormatterBiome} from "../typescript/common/typescript-formatter.biome" - -export const filename = path.resolve(__filename) - -const whenFormatter = TypescriptFormatterBiome.createNodeFormatter() - -export default async function typecheck( - compilationUnits: {filename: string; content: string}[], -) { - const formatter = await whenFormatter - - const files: Record = {} - - for (const unit of compilationUnits) { - const formatted = await formatter.format(unit.filename, unit.content) - - if (formatted.err) { - throw formatted.err - } - - files[path.resolve(unit.filename)] = formatted.result - } - - const fileNames = Object.keys(files) - - const compilerHost = ts.createCompilerHost({}, true) - - const readFile = (fileName: string): string => { - const file = files[fileName] - - if ( - file === undefined && - fileName.startsWith("/") && - fs.existsSync(fileName) - ) { - return fs.readFileSync(fileName, "utf-8").toString() - } - - if (file === undefined) { - throw new Error(`file '${fileName}' not found`) - } - - return file - } - - compilerHost.readFile = readFile - - compilerHost.fileExists = (fileName: string) => { - return fileName in files - } - - compilerHost.getSourceFile = (fileName, languageVersion) => { - const file = readFile(fileName) - - return ts.createSourceFile(fileName, file, languageVersion) - } - - compilerHost.writeFile = () => { - /* noop */ - } - - const program = ts.createProgram({ - rootNames: fileNames, - options: { - noEmit: true, - strict: true, - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - types: [], - }, - host: compilerHost, - }) - - const diagnostics = ts.getPreEmitDiagnostics(program) - - if (diagnostics.length > 0) { - const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { - getCurrentDirectory: () => "", - getCanonicalFileName: (fileName) => fileName, - getNewLine: () => "\n", - }) - throw new Error(`TypeScript compilation failed:\n\n${formatted}`) - } -} diff --git a/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.spec.ts b/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.spec.ts new file mode 100644 index 000000000..792d18d0c --- /dev/null +++ b/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.spec.ts @@ -0,0 +1,34 @@ +import {describe, expect, it} from "@jest/globals" +import {TestOutputTypeChecker} from "./typescript-compiler.test-utils" + +describe("test/typescript-compiler", () => { + const typechecker = new TestOutputTypeChecker() + + it("should throw is provided code doesn't typecheck successfully", async () => { + expect(() => + typechecker.typecheck([ + { + filename: "unit-test.ts", + content: ` + const x = "foo" + const y: number = x + `, + }, + ]), + ).toThrow(/TS2322: Type 'string' is not assignable to type 'number'/) + }) + + it("should not throw if the provided code is valid", async () => { + expect(() => + typechecker.typecheck([ + { + filename: "unit-test.ts", + content: ` + const x = "foo" + const y: string = x + `, + }, + ]), + ).not.toThrow() + }) +}) diff --git a/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.ts b/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.ts new file mode 100644 index 000000000..9f33ca9f9 --- /dev/null +++ b/packages/openapi-code-generator/src/test/typescript-compiler.test-utils.ts @@ -0,0 +1,103 @@ +import fs from "node:fs" +import path from "node:path" +import util from "node:util" +import ts from "typescript" + +export class TestOutputTypeChecker { + private files: Record = {} + private readonly sourceFileCache: Record = + {} + + private readonly compilerHost: ts.CompilerHost + private readonly options: ts.CompilerOptions = { + noEmit: true, + strict: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + skipLibCheck: true, + types: [], + } + private program: ts.Program + + constructor() { + const defaultHost = ts.createCompilerHost(this.options, true) + + const compilerHost = { + ...defaultHost, + fileExists: (fileName: string) => { + return fileName in this.files + }, + readFile: (fileName: string): string => { + const file = this.files[fileName] + + if (file === undefined && fileName.startsWith("/")) { + return fs.readFileSync(fileName, "utf-8").toString() + } + + if (file === undefined) { + throw new Error(`file '${fileName}' not found`) + } + + return file + }, + writeFile: () => { + /* noop */ + }, + getSourceFile: (fileName, languageVersion) => { + if (this.sourceFileCache[fileName]) { + return this.sourceFileCache[fileName] + } + + const file = compilerHost.readFile(fileName) + const result = ts.createSourceFile(fileName, file, languageVersion) + this.sourceFileCache[fileName] = result + + return result + }, + } satisfies ts.CompilerHost + + this.compilerHost = compilerHost + this.program = ts.createProgram({ + rootNames: [], + options: this.options, + host: this.compilerHost, + }) + } + + typecheck(compilationUnits: {filename: string; content: string}[]) { + const rootNames: string[] = [] + + for (const filename in this.files) { + this.sourceFileCache[filename] = undefined + } + + this.files = {} + + for (const unit of compilationUnits) { + const filename = path.resolve(unit.filename) + this.files[filename] = unit.content + this.sourceFileCache[filename] = undefined + rootNames.push(filename) + } + + this.program = ts.createProgram({ + rootNames, + options: this.options, + host: this.compilerHost, + oldProgram: this.program, + }) + + const diagnostics = ts.getPreEmitDiagnostics(this.program) + + if (diagnostics.length > 0) { + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => "", + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => "\n", + }) + throw new Error( + `TypeScript compilation failed:\n\n${util.stripVTControlCharacters(formatted)}`, + ) + } + } +} diff --git a/packages/openapi-code-generator/src/test/workerWrapper.js b/packages/openapi-code-generator/src/test/workerWrapper.js deleted file mode 100644 index e55bc3fee..000000000 --- a/packages/openapi-code-generator/src/test/workerWrapper.js +++ /dev/null @@ -1,4 +0,0 @@ -const tsx = require("tsx/cjs/api") -const {workerData} = require("node:worker_threads") - -module.exports = tsx.require(workerData.fullpath, __filename) diff --git a/packages/openapi-code-generator/src/typescript/client/abstract-client-builder.ts b/packages/openapi-code-generator/src/typescript/client/abstract-client-builder.ts index 3ab2764d6..1d1e4d550 100644 --- a/packages/openapi-code-generator/src/typescript/client/abstract-client-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/abstract-client-builder.ts @@ -3,7 +3,7 @@ import type {IROperation} from "../../core/openapi-types-normalized" import {CompilationUnit, type ICompilable} from "../common/compilation-units" import type {ImportBuilder} from "../common/import-builder" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../common/type-builder" +import type {TypeBuilder} from "../common/type-builder/type-builder" import {union} from "../common/type-utils" import {ClientOperationBuilder} from "./client-operation-builder" import {ClientServersBuilder} from "./client-servers-builder" diff --git a/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts b/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts index a2e05526c..eac941ce2 100644 --- a/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts @@ -8,7 +8,7 @@ import type { import {extractPlaceholders} from "../../core/openapi-utils" import {camelCase, isDefined} from "../../core/utils" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../common/type-builder" +import type {TypeBuilder} from "../common/type-builder/type-builder" import {object, quotedStringLiteral} from "../common/type-utils" import { combineParams, diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-angular/typescript-angular.generator.ts b/packages/openapi-code-generator/src/typescript/client/typescript-angular/typescript-angular.generator.ts index 271dfcb0c..1ae17938f 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-angular/typescript-angular.generator.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-angular/typescript-angular.generator.ts @@ -2,7 +2,7 @@ import {titleCase} from "../../../core/utils" import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {AngularModuleBuilder} from "./angular-module-builder" import {AngularServiceBuilder} from "./angular-service-builder" @@ -17,7 +17,7 @@ export async function generateTypescriptAngular( const moduleImports = new ImportBuilder(importBuilderConfig) const serviceImports = new ImportBuilder(importBuilderConfig) - const rootTypeBuilder = await TypeBuilder.fromInput( + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( "./models.ts", input, config.compilerOptions, diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios.generator.ts b/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios.generator.ts index 79e29af13..b868a4820 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios.generator.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios.generator.ts @@ -2,7 +2,7 @@ import {titleCase} from "../../../core/utils" import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {TypescriptAxiosClientBuilder} from "./typescript-axios-client-builder" export async function generateTypescriptAxios( @@ -15,7 +15,7 @@ export async function generateTypescriptAxios( const schemaBuilderImports = new ImportBuilder(importBuilderConfig) const clientImports = new ImportBuilder(importBuilderConfig) - const rootTypeBuilder = await TypeBuilder.fromInput( + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( "./models.ts", input, config.compilerOptions, diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch.generator.ts b/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch.generator.ts index 42ba872c3..6018dcd87 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch.generator.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch.generator.ts @@ -2,7 +2,7 @@ import {titleCase} from "../../../core/utils" import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {TypescriptFetchClientBuilder} from "./typescript-fetch-client-builder" export async function generateTypescriptFetch( @@ -14,7 +14,7 @@ export async function generateTypescriptFetch( const schemaBuilderImports = new ImportBuilder(importBuilderConfig) - const rootTypeBuilder = await TypeBuilder.fromInput( + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( "./models.ts", input, config.compilerOptions, diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts index f2e795f74..ce6e5248a 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts @@ -17,7 +17,7 @@ import type { import {getNameFromRef, isRef} from "../../../core/openapi-utils" import {CompilationUnit, type ICompilable} from "../compilation-units" import type {ImportBuilder} from "../import-builder" -import type {TypeBuilder} from "../type-builder" +import type {TypeBuilder} from "../type-builder/type-builder" import {buildExport, type ExportDefinition} from "../typescript-common" import type {SchemaBuilderType} from "./schema-builder" diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts index a6e151d57..e8120d98d 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts @@ -12,7 +12,7 @@ import type { import {isRef} from "../../../core/openapi-utils" import {hasSingleElement, isDefined} from "../../../core/utils" import type {ImportBuilder} from "../import-builder" -import type {TypeBuilder} from "../type-builder" +import type {TypeBuilder} from "../type-builder/type-builder" import {quotedStringLiteral} from "../type-utils" import type {ExportDefinition} from "../typescript-common" import { diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts index c98d593d1..8fb408818 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts @@ -13,7 +13,7 @@ import { unitTestInput, } from "../../../test/input.test-utils" import {ImportBuilder} from "../import-builder" -import {TypeBuilder} from "../type-builder" +import {TypeBuilder} from "../type-builder/type-builder" import {TypescriptFormatterBiome} from "../typescript-formatter.biome" import type {SchemaBuilderConfig} from "./abstract-schema-builder" import {type SchemaBuilderType, schemaBuilderFactory} from "./schema-builder" @@ -56,7 +56,7 @@ export function schemaBuilderTestHarness( const imports = new ImportBuilder({includeFileExtensions: false}) - const typeBuilder = await TypeBuilder.fromInput( + const typeBuilder = await TypeBuilder.fromSchemaProvider( "./unit-test.types.ts", input, {exactOptionalPropertyTypes: false}, diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.ts index ea936b7bc..e750f5c64 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.ts @@ -1,6 +1,6 @@ import type {Input} from "../../../core/input" import type {ImportBuilder} from "../import-builder" -import type {TypeBuilder} from "../type-builder" +import type {TypeBuilder} from "../type-builder/type-builder" import type {SchemaBuilderConfig} from "./abstract-schema-builder" import {JoiBuilder} from "./joi-schema-builder" import {ZodV3Builder} from "./zod-v3-schema-builder" diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts index f84dd0217..a09e9ab75 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts @@ -12,7 +12,7 @@ import type { import {isRef} from "../../../core/openapi-utils" import {hasSingleElement, isDefined} from "../../../core/utils" import type {ImportBuilder} from "../import-builder" -import type {TypeBuilder} from "../type-builder" +import type {TypeBuilder} from "../type-builder/type-builder" import {quotedStringLiteral} from "../type-utils" import type {ExportDefinition} from "../typescript-common" import { diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts index 536d6f6ae..006b07444 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts @@ -12,7 +12,7 @@ import type { import {isRef} from "../../../core/openapi-utils" import {hasSingleElement, isDefined} from "../../../core/utils" import type {ImportBuilder} from "../import-builder" -import type {TypeBuilder} from "../type-builder" +import type {TypeBuilder} from "../type-builder/type-builder" import {quotedStringLiteral} from "../type-utils" import type {ExportDefinition} from "../typescript-common" import { diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.integration.spec.ts similarity index 64% rename from packages/openapi-code-generator/src/typescript/common/type-builder.spec.ts rename to packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.integration.spec.ts index 86a67590e..cdc45f261 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.integration.spec.ts @@ -1,18 +1,24 @@ -import {describe, expect, it} from "@jest/globals" -import type {Input} from "../../core/input" -import type {CompilerOptions} from "../../core/loaders/tsconfig.loader" -import type {IRModel, MaybeIRModel} from "../../core/openapi-types-normalized" -import {testVersions, unitTestInput} from "../../test/input.test-utils" -import {irFixture as ir} from "../../test/ir-model.fixtures.test-utils" -import typecheck from "../../test/typescript-compiler-worker.test-utils" -import {CompilationUnit} from "./compilation-units" -import {ImportBuilder} from "./import-builder" -import {TypeBuilder, type TypeBuilderConfig} from "./type-builder" -import {TypescriptFormatterBiome} from "./typescript-formatter.biome" +import {beforeAll, describe, expect, it} from "@jest/globals" +import type {CompilerOptions} from "../../../core/loaders/tsconfig.loader" +import {testVersions, unitTestInput} from "../../../test/input.test-utils" +import {TypescriptFormatterBiome} from "../typescript-formatter.biome" +import type {TypeBuilderConfig} from "./type-builder" +import { + type TypeBuilderTestHarness, + typeBuilderTestHarness, +} from "./type-builder.test-utils" describe.each( testVersions, )("%s - typescript/common/type-builder", (version) => { + let formatter: TypescriptFormatterBiome + let testHarness: TypeBuilderTestHarness + + beforeAll(async () => { + formatter = await TypescriptFormatterBiome.createNodeFormatter() + testHarness = typeBuilderTestHarness(formatter) + }) + it("can build a type for a simple object correctly", async () => { const {code, types} = await getActual("components/schemas/SimpleObject") @@ -406,181 +412,6 @@ describe.each( }) }) - describe("intersections / unions", () => { - it("can handle a basic A | B", async () => { - const {code} = await getActualFromModel( - ir.union({ - schemas: [ir.string(), ir.number()], - }), - ) - - expect(code).toMatchInlineSnapshot(`"declare const x: string | number"`) - }) - - it("can handle a basic A | B | C | D", async () => { - const {code} = await getActualFromModel( - ir.union({ - schemas: [ - ir.string(), - ir.union({ - schemas: [ir.string(), ir.number(), ir.boolean()], - }), - ], - }), - ) - - expect(code).toMatchInlineSnapshot( - `"declare const x: string | number | boolean"`, - ) - }) - - it("can handle a basic A & B", async () => { - const {code} = await getActualFromModel( - ir.intersection({ - schemas: [ - ir.object({properties: {a: ir.string()}}), - ir.object({properties: {b: ir.string()}}), - ], - }), - ) - - expect(code).toMatchInlineSnapshot(` - "declare const x: { - a?: string - } & { - b?: string - }" - `) - }) - - it("can unnest a basic A & B & C", async () => { - const {code} = await getActualFromModel( - ir.intersection({ - schemas: [ - ir.object({properties: {a: ir.string()}}), - ir.intersection({ - schemas: [ - ir.object({properties: {b: ir.string()}}), - ir.object({properties: {c: ir.string()}}), - ], - }), - ], - }), - ) - - expect(code).toMatchInlineSnapshot(` - "declare const x: { - a?: string - } & { - b?: string - } & { - c?: string - }" - `) - }) - - it("can handle intersecting an object with a union A & (B | C)", async () => { - const {code} = await getActualFromModel( - ir.intersection({ - schemas: [ - ir.object({ - properties: {base: ir.string()}, - required: ["base"], - }), - ir.union({ - schemas: [ - ir.object({ - properties: {a: ir.number()}, - required: ["a"], - }), - ir.object({ - properties: {a: ir.string()}, - required: ["a"], - }), - ], - }), - ], - }), - ) - - expect(code).toMatchInlineSnapshot(` - "declare const x: { - base: string - } & ( - | { - a: number - } - | { - a: string - } - )" - `) - }) - - it("can handle intersecting an union with a union (A | B) & (D | C)", async () => { - const {code} = await getActualFromModel( - ir.intersection({ - schemas: [ - ir.union({ - schemas: [ - ir.object({ - properties: {a: ir.number()}, - required: ["a"], - }), - ir.object({ - properties: {a: ir.string()}, - required: ["a"], - }), - ], - }), - ir.union({ - schemas: [ - ir.object({ - properties: {a: ir.number()}, - required: ["b"], - }), - ir.object({ - properties: {a: ir.string()}, - required: ["b"], - }), - ], - }), - ], - }), - ) - - expect(code).toMatchInlineSnapshot(` - "declare const x: ( - | { - a: number - } - | { - a: string - } - ) & - ( - | { - a?: number - } - | { - a?: string - } - )" - `) - }) - }) - - async function getActualFromModel( - schema: IRModel, - config: { - config?: TypeBuilderConfig - compilerOptions?: CompilerOptions - } = {}, - ) { - const {input} = await unitTestInput(version) - return getResult(schema, input, config) - } - async function getActual( path: string, config: { @@ -590,76 +421,6 @@ describe.each( ) { const {input, file} = await unitTestInput(version) const schema = {$ref: `${file}#/${path}`} - return getResult(schema, input, config) - } - - async function getResult( - schema: MaybeIRModel, - input: Input, - { - config = {allowAny: false}, - compilerOptions = {exactOptionalPropertyTypes: false}, - }: { - config?: TypeBuilderConfig - compilerOptions?: CompilerOptions - }, - ) { - const formatter = await TypescriptFormatterBiome.createNodeFormatter() - - const imports = new ImportBuilder({includeFileExtensions: false}) - - const builder = await TypeBuilder.fromInput( - "./unit-test.types.ts", - input, - compilerOptions, - config, - ) - - const type = builder.withImports(imports).schemaObjectToType(schema) - - const usage = new CompilationUnit( - "./unit-test.code.ts", - imports, - `declare const x: ${type}`, - ) - const types = builder.toCompilationUnit() - - await typecheck([ - { - filename: usage.filename, - content: usage.getRawFileContent({ - allowUnusedImports: false, - includeHeader: false, - }), - }, - { - filename: types.filename, - content: types.getRawFileContent({ - allowUnusedImports: false, - includeHeader: false, - }), - }, - ]) - - return { - code: ( - await formatter.format( - usage.filename, - usage.getRawFileContent({ - allowUnusedImports: false, - includeHeader: false, - }), - ) - ).result.trim(), - types: ( - await formatter.format( - types.filename, - types.getRawFileContent({ - allowUnusedImports: false, - includeHeader: false, - }), - ) - ).result.trim(), - } + return testHarness.getActual(schema, input, config) } }) diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.test-utils.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.test-utils.ts new file mode 100644 index 000000000..aeff18f86 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.test-utils.ts @@ -0,0 +1,95 @@ +import type {ISchemaProvider} from "../../../core/input" +import type {IFormatter} from "../../../core/interfaces" +import type {CompilerOptions} from "../../../core/loaders/tsconfig.loader" +import type {MaybeIRModel} from "../../../core/openapi-types-normalized" +import {TestOutputTypeChecker} from "../../../test/typescript-compiler.test-utils" +import {CompilationUnit} from "../compilation-units" +import {ImportBuilder} from "../import-builder" +import {TypeBuilder, type TypeBuilderConfig} from "./type-builder" + +export type TypeBuilderTestHarness = { + getActual: ( + schema: MaybeIRModel, + schemaProvider: ISchemaProvider, + config: { + config?: TypeBuilderConfig + compilerOptions?: CompilerOptions + }, + ) => Promise<{code: string; types: string}> +} + +export function typeBuilderTestHarness( + formatter: IFormatter, +): TypeBuilderTestHarness { + const typechecker = new TestOutputTypeChecker() + + async function getActual( + schema: MaybeIRModel, + input: ISchemaProvider, + { + config = {allowAny: false}, + compilerOptions = {exactOptionalPropertyTypes: false}, + }: { + config?: TypeBuilderConfig + compilerOptions?: CompilerOptions + }, + ) { + const imports = new ImportBuilder({includeFileExtensions: false}) + + const builder = await TypeBuilder.fromSchemaProvider( + "./unit-test.types.ts", + input, + compilerOptions, + config, + ) + + const type = builder.withImports(imports).schemaObjectToType(schema) + + const usage = await formatted( + formatter, + new CompilationUnit( + "./unit-test.code.ts", + imports, + `declare const x: ${type}`, + ), + ) + + const types = await formatted(formatter, builder.toCompilationUnit()) + + typechecker.typecheck([ + { + filename: usage.filename, + content: usage.content, + }, + { + filename: types.filename, + content: types.content, + }, + ]) + + return { + code: usage.content, + types: types.content, + } + } + + async function formatted( + formatter: IFormatter, + unit: CompilationUnit, + ): Promise<{ + filename: string + content: string + }> { + const raw = unit.getRawFileContent({ + allowUnusedImports: false, + includeHeader: false, + }) + + return { + filename: unit.filename, + content: (await formatter.format(unit.filename, raw)).result.trim(), + } + } + + return {getActual} +} diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts similarity index 85% rename from packages/openapi-code-generator/src/typescript/common/type-builder.ts rename to packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts index 9fec5c8af..13fed88e8 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts @@ -1,12 +1,12 @@ -import type {Input} from "../../core/input" -import type {CompilerOptions} from "../../core/loaders/tsconfig.loader" -import {logger} from "../../core/logger" -import type {Reference} from "../../core/openapi-types" -import type {MaybeIRModel} from "../../core/openapi-types-normalized" -import {getNameFromRef, isRef} from "../../core/openapi-utils" -import {hasSingleElement} from "../../core/utils" -import {CompilationUnit, type ICompilable} from "./compilation-units" -import type {ImportBuilder} from "./import-builder" +import type {ISchemaProvider} from "../../../core/input" +import type {CompilerOptions} from "../../../core/loaders/tsconfig.loader" +import {logger} from "../../../core/logger" +import type {Reference} from "../../../core/openapi-types" +import type {MaybeIRModel} from "../../../core/openapi-types-normalized" +import {getNameFromRef, isRef} from "../../../core/openapi-utils" +import {hasSingleElement} from "../../../core/utils" +import {CompilationUnit, type ICompilable} from "../compilation-units" +import type {ImportBuilder} from "../import-builder" import { array, coerceToString, @@ -15,8 +15,8 @@ import { objectProperty, quotedStringLiteral, union, -} from "./type-utils" -import {buildExport} from "./typescript-common" +} from "../type-utils" +import {buildExport} from "../typescript-common" const staticTypes = { EmptyObject: "export type EmptyObject = { [key: string]: never }", @@ -43,21 +43,24 @@ type IRType = IRTypeIntersection | IRTypeUnion | IRTypeOther /** * Recursively looks for opportunities to merge / flatten intersections and unions */ -export function normalizeIRType(type: IRType): IRType { +function normalizeIRType(type: IRType): IRType { if (typeof type === "string") { return type } if (type.type === "type-intersection" || type.type === "type-union") { const flattened: IRType[] = [] - + const seen = new Set() for (const innerType of type.types) { const normalized = normalizeIRType(innerType) if (typeof normalized !== "string" && normalized.type === type.type) { flattened.push(...normalized.types) - } else { + } else if (typeof normalized !== "string") { + flattened.push(normalized) + } else if (!seen.has(normalized)) { flattened.push(normalized) + seen.add(normalized) } } @@ -71,13 +74,16 @@ export function normalizeIRType(type: IRType): IRType { } } - return type + /* istanbul ignore next */ + throw new Error( + `normalizeIRType: unknown IRType '${JSON.stringify(type satisfies never)}'`, + ) } /** * Converts IRType to a typescript type */ -export function toTs(type: IRType): string { +function toTs(type: IRType): string { if (typeof type === "string") { return type } else if (type.type === "type-union") { @@ -86,13 +92,14 @@ export function toTs(type: IRType): string { return intersect(...type.types.map(toTs)) } + /* istanbul ignore next */ throw new Error(`toTs: unknown type '${JSON.stringify(type)}'`) } export class TypeBuilder implements ICompilable { private constructor( public readonly filename: string, - private readonly input: Input, + private readonly input: ISchemaProvider, private readonly compilerOptions: CompilerOptions, private readonly config: TypeBuilderConfig, private readonly referenced = new Set(), @@ -101,13 +108,18 @@ export class TypeBuilder implements ICompilable { private readonly parent?: TypeBuilder, ) {} - static async fromInput( + static async fromSchemaProvider( filename: string, - input: Input, + schemaProvider: ISchemaProvider, compilerOptions: CompilerOptions, typeBuilderConfig: TypeBuilderConfig, ): Promise { - return new TypeBuilder(filename, input, compilerOptions, typeBuilderConfig) + return new TypeBuilder( + filename, + schemaProvider, + compilerOptions, + typeBuilderConfig, + ) } withImports(imports: ImportBuilder): TypeBuilder { @@ -309,8 +321,7 @@ export class TypeBuilder implements ICompilable { } case "any": { - result.push(this.config.allowAny ? "any" : "unknown") - break + return this.config.allowAny ? "any" : "unknown" } case "never": { @@ -319,7 +330,9 @@ export class TypeBuilder implements ICompilable { } case "null": { - throw new Error("unreachable - input should normalize this out") + throw new Error( + "unreachable - 'null' types should be normalized out by SchemaNormalizer", + ) } case "record": { diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts new file mode 100644 index 000000000..308f652bc --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts @@ -0,0 +1,645 @@ +import {beforeAll, beforeEach, describe, expect, it} from "@jest/globals" +import type {ISchemaProvider} from "../../../core/input" +import type {CompilerOptions} from "../../../core/loaders/tsconfig.loader" +import type { + IRModel, + IRRef, + MaybeIRModel, +} from "../../../core/openapi-types-normalized" +import {isRef} from "../../../core/openapi-utils" +import {irFixture as ir} from "../../../test/ir-model.fixtures.test-utils" +import {TypescriptFormatterBiome} from "../typescript-formatter.biome" +import type {TypeBuilderConfig} from "./type-builder" +import { + type TypeBuilderTestHarness, + typeBuilderTestHarness, +} from "./type-builder.test-utils" + +class FakeSchemaProvider implements ISchemaProvider { + private readonly testRefs: Record = {} + + registerTestRef(ref: IRRef, model: IRModel) { + this.testRefs[ref.$ref] = model + } + + schema(maybeRef: MaybeIRModel): IRModel { + if (isRef(maybeRef)) { + const result = this.testRefs[maybeRef.$ref] + + if (!result) { + throw new Error( + `FakeSchemaProvider: $ref '${maybeRef.$ref}' is not registered`, + ) + } + + return result + } + + return maybeRef + } +} + +describe("typescript/common/type-builder - unit tests", () => { + let formatter: TypescriptFormatterBiome + let schemaProvider: FakeSchemaProvider + let testHarness: TypeBuilderTestHarness + + beforeAll(async () => { + formatter = await TypescriptFormatterBiome.createNodeFormatter() + testHarness = typeBuilderTestHarness(formatter) + }) + + beforeEach(async () => { + schemaProvider = new FakeSchemaProvider() + }) + + describe("$ref", () => { + it("will convert a $ref to a name and emit the referenced type", async () => { + const ref = ir.ref("/components/schemas/User") + schemaProvider.registerTestRef( + ref, + ir.object({properties: {username: ir.string()}}), + ) + + const {code, types} = await getActual( + ir.object({properties: {user: ref}}), + ) + + expect(types).toMatchInlineSnapshot(` + "export type t_User = { + username?: string + }" + `) + expect(code).toMatchInlineSnapshot(` + "import type { t_User } from "./unit-test.types" + + declare const x: { + user?: t_User + }" + `) + }) + }) + + describe("strings", () => { + it("handles a basic string", async () => { + const {code} = await getActual(ir.string()) + expect(code).toMatchInlineSnapshot(`"declare const x: string"`) + }) + + it("handles a nullable string", async () => { + const {code} = await getActual(ir.string({nullable: true})) + expect(code).toMatchInlineSnapshot(`"declare const x: string | null"`) + }) + + it("handles a 'closed' enum string", async () => { + const {code} = await getActual( + ir.string({ + enum: ["one", "two", "three"], + "x-enum-extensibility": "closed", + }), + ) + expect(code).toMatchInlineSnapshot( + `"declare const x: "one" | "two" | "three""`, + ) + }) + + it("handles a 'open' enum string", async () => { + const {code} = await getActual( + ir.string({ + enum: ["one", "two", "three"], + "x-enum-extensibility": "open", + }), + ) + expect(code).toMatchInlineSnapshot(` + "import type { UnknownEnumStringValue } from "./unit-test.types" + + declare const x: "one" | "two" | "three" | UnknownEnumStringValue" + `) + }) + + it("handles a nullable enum string", async () => { + const {code} = await getActual( + ir.string({nullable: true, enum: ["foo", "bar"]}), + ) + expect(code).toMatchInlineSnapshot( + `"declare const x: "foo" | "bar" | null"`, + ) + }) + + it("handles a binary format string", async () => { + const {code} = await getActual(ir.string({format: "binary"})) + + expect(code).toMatchInlineSnapshot(`"declare const x: Blob"`) + }) + }) + + describe("numbers", () => { + it("handles a basic number", async () => { + const {code} = await getActual(ir.number()) + expect(code).toMatchInlineSnapshot(`"declare const x: number"`) + }) + + it("handles a nullable number", async () => { + const {code} = await getActual(ir.number({nullable: true})) + expect(code).toMatchInlineSnapshot(`"declare const x: number | null"`) + }) + + it("handles a 'closed' enum number", async () => { + const {code} = await getActual( + ir.number({ + enum: [1, 2, 3], + "x-enum-extensibility": "closed", + }), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: 1 | 2 | 3"`) + }) + + it("handles a 'open' enum number", async () => { + const {code} = await getActual( + ir.number({ + enum: [1, 2, 3], + "x-enum-extensibility": "open", + }), + ) + expect(code).toMatchInlineSnapshot(` + "import type { UnknownEnumNumberValue } from "./unit-test.types" + + declare const x: 1 | 2 | 3 | UnknownEnumNumberValue" + `) + }) + + it("handles a nullable enum number", async () => { + const {code} = await getActual( + ir.number({nullable: true, enum: [10, 12]}), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: 10 | 12 | null"`) + }) + }) + + describe("booleans", () => { + it("handles a basic boolean", async () => { + const {code} = await getActual(ir.boolean()) + expect(code).toMatchInlineSnapshot(`"declare const x: boolean"`) + }) + + it("handles a nullable boolean", async () => { + const {code} = await getActual(ir.boolean({nullable: true})) + expect(code).toMatchInlineSnapshot(`"declare const x: boolean | null"`) + }) + + it("handles a enum boolean (true)", async () => { + const {code} = await getActual(ir.boolean({enum: ["true"]})) + expect(code).toMatchInlineSnapshot(`"declare const x: true"`) + }) + + it("handles a enum boolean (false)", async () => { + const {code} = await getActual(ir.boolean({enum: ["false"]})) + expect(code).toMatchInlineSnapshot(`"declare const x: false"`) + }) + + it("handles a nullable enum boolean", async () => { + const {code} = await getActual( + ir.boolean({enum: ["false"], nullable: true}), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: false | null"`) + }) + }) + + describe("objects", () => { + it("handles a basic object", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.string(), + b: ir.number(), + c: ir.boolean({nullable: true}), + d: ir.boolean({nullable: true}), + }, + required: ["a", "d"], + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a: string + b?: number + c?: boolean | null + d: boolean | null + }" + `) + }) + + it("handles a nullable object", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.string(), + }, + nullable: true, + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a?: string + } | null" + `) + }) + + it("handles an object with additionalProperties", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.number(), + }, + // todo: a Record here will cause a typescript error for conflicting with `a` + // we should probably detect this earlier, and raise a more user friendly error at + // the input processing level + additionalProperties: ir.record({value: ir.number()}), + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a?: number + [key: string]: number | undefined + }" + `) + }) + + it("handles nested objects", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.object({ + properties: { + b: ir.string(), + }, + required: ["b"], + }), + }, + required: ["a"], + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a: { + b: string + } + }" + `) + }) + + it("handles objects with nullable properties", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.string({nullable: true}), + }, + required: ["a"], + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a: string | null + }" + `) + }) + + it("handles objects with properties that are arrays of objects", async () => { + const {code} = await getActual( + ir.object({ + properties: { + a: ir.array({ + items: ir.object({ + properties: { + b: ir.string(), + }, + }), + }), + }, + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a?: { + b?: string + }[] + }" + `) + }) + }) + + describe("records", () => { + it("handles a basic record", async () => { + const {code} = await getActual( + ir.record({ + value: ir.string(), + }), + ) + expect(code).toMatchInlineSnapshot( + `"declare const x: Record"`, + ) + }) + + it("handles a nullable record", async () => { + const {code} = await getActual( + ir.record({ + value: ir.string(), + nullable: true, + }), + ) + expect(code).toMatchInlineSnapshot( + `"declare const x: Record | null"`, + ) + }) + }) + + describe("arrays", () => { + it("handles a basic array", async () => { + const {code} = await getActual(ir.array({items: ir.string()})) + expect(code).toMatchInlineSnapshot(`"declare const x: string[]"`) + }) + + it("handles a nullable array", async () => { + const {code} = await getActual( + ir.array({items: ir.string(), nullable: true}), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: string[] | null"`) + }) + + it("handles an array of objects", async () => { + const {code} = await getActual( + ir.array({ + items: ir.object({ + properties: { + a: ir.string(), + }, + required: ["a"], + }), + }), + ) + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a: string + }[]" + `) + }) + + it("handles an array with nullable items", async () => { + const {code} = await getActual( + ir.array({ + items: ir.string({nullable: true}), + }), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: (string | null)[]"`) + }) + + it("handles nested arrays", async () => { + const {code} = await getActual( + ir.array({ + items: ir.array({ + items: ir.string(), + }), + }), + ) + expect(code).toMatchInlineSnapshot(`"declare const x: string[][]"`) + }) + }) + + describe("any", () => { + it("converts into 'any' when allowAny: true", async () => { + const {code} = await getActual(ir.any(), { + config: {allowAny: true}, + }) + expect(code).toMatchInlineSnapshot(`"declare const x: any"`) + }) + + it("converts into 'unknown' when allowAny: false", async () => { + const {code} = await getActual(ir.any(), { + config: {allowAny: false}, + }) + expect(code).toMatchInlineSnapshot(`"declare const x: unknown"`) + }) + + it("ignores nullability", async () => { + const {code} = await getActual(ir.any({nullable: true}), { + config: {allowAny: false}, + }) + expect(code).toMatchInlineSnapshot(`"declare const x: unknown"`) + }) + }) + + describe("never", () => { + it("handles never", async () => { + const {code} = await getActual(ir.never()) + expect(code).toMatchInlineSnapshot(`"declare const x: never"`) + }) + }) + + describe("intersections / unions", () => { + it("can handle a basic A | B", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ir.string(), ir.number()], + }), + ) + + expect(code).toMatchInlineSnapshot(`"declare const x: string | number"`) + }) + + it("can unwrap & deduplicate a nested union into A | B | C", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ + ir.string(), + ir.union({ + schemas: [ir.string(), ir.number(), ir.boolean()], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot( + `"declare const x: string | number | boolean"`, + ) + }) + + it("can flatten a union into A", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ + ir.string(), + ir.union({ + schemas: [ir.string(), ir.string()], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(`"declare const x: string"`) + }) + + it("can handle a basic A & B", async () => { + const {code} = await getActual( + ir.intersection({ + schemas: [ + ir.object({properties: {a: ir.string()}}), + ir.object({properties: {b: ir.string()}}), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a?: string + } & { + b?: string + }" + `) + }) + + it("can unnest a basic A & B & C", async () => { + const {code} = await getActual( + ir.intersection({ + schemas: [ + ir.object({properties: {a: ir.string()}}), + ir.intersection({ + schemas: [ + ir.object({properties: {b: ir.string()}}), + ir.object({properties: {c: ir.string()}}), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "declare const x: { + a?: string + } & { + b?: string + } & { + c?: string + }" + `) + }) + + it("can handle intersecting an object with a union A & (B | C)", async () => { + const {code} = await getActual( + ir.intersection({ + schemas: [ + ir.object({ + properties: {base: ir.string()}, + required: ["base"], + }), + ir.union({ + schemas: [ + ir.object({ + properties: {a: ir.number()}, + required: ["a"], + }), + ir.object({ + properties: {a: ir.string()}, + required: ["a"], + }), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "declare const x: { + base: string + } & ( + | { + a: number + } + | { + a: string + } + )" + `) + }) + + it("can handle intersecting an union with a union (A | B) & (D | C)", async () => { + const {code} = await getActual( + ir.intersection({ + schemas: [ + ir.union({ + schemas: [ + ir.object({ + properties: {a: ir.number()}, + required: ["a"], + }), + ir.object({ + properties: {a: ir.string()}, + required: ["a"], + }), + ], + }), + ir.union({ + schemas: [ + ir.object({ + properties: {a: ir.number()}, + required: ["b"], + }), + ir.object({ + properties: {a: ir.string()}, + required: ["b"], + }), + ], + }), + ], + }), + ) + + expect(code).toMatchInlineSnapshot(` + "declare const x: ( + | { + a: number + } + | { + a: string + } + ) & + ( + | { + a?: number + } + | { + a?: string + } + )" + `) + }) + }) + + it("throws if accidentally passed a 'null' type", async () => { + await expect(getActual(ir.null())).rejects.toThrow( + "unreachable - 'null' types should be normalized out by SchemaNormalizer", + ) + }) + + it("throws if passed a garbage type", async () => { + await expect( + getActual({type: "rubbish"} as unknown as IRModel), + ).rejects.toThrow(`unsupported type '{ + "type": "rubbish" +}'`) + }) + + async function getActual( + schema: IRModel, + { + config = {allowAny: false}, + compilerOptions = {exactOptionalPropertyTypes: false}, + }: { + config?: TypeBuilderConfig + compilerOptions?: CompilerOptions + } = {}, + ) { + return testHarness.getActual(schema, schemaProvider, { + config, + compilerOptions, + }) + } +}) diff --git a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts index 33800abcb..96f657493 100644 --- a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts @@ -3,7 +3,7 @@ import type {IROperation} from "../../core/openapi-types-normalized" import {CompilationUnit, type ICompilable} from "../common/compilation-units" import type {ImportBuilder} from "../common/import-builder" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../common/type-builder" +import type {TypeBuilder} from "../common/type-builder/type-builder" import { ServerOperationBuilder, type ServerSymbols, diff --git a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts index 778c770c1..99fc98387 100644 --- a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts @@ -9,7 +9,7 @@ import type {IRModel, IROperation} from "../../core/openapi-types-normalized" import {extractPlaceholders} from "../../core/openapi-utils" import {convertBytesToHuman} from "../../core/utils" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../common/type-builder" +import type {TypeBuilder} from "../common/type-builder/type-builder" import {intersect, object} from "../common/type-utils" import { requestBodyAsParameter, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts index f1df55067..b71345480 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts @@ -3,7 +3,7 @@ import {titleCase} from "../../../core/utils" import type {ServerImplementationMethod} from "../../../templates.types" import type {ImportBuilder} from "../../common/import-builder" import type {SchemaBuilder} from "../../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../../common/type-builder" +import type {TypeBuilder} from "../../common/type-builder/type-builder" import { constStatement, object, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express.generator.ts b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express.generator.ts index 90ad56988..f305ef47d 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express.generator.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express.generator.ts @@ -4,7 +4,7 @@ import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types" import {CompilationUnit} from "../../common/compilation-units" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {ExpressRouterBuilder} from "./typescript-express-router-builder" import {ExpressServerBuilder} from "./typescript-express-server-builder" @@ -20,7 +20,7 @@ export async function generateTypescriptExpress( const schemaBuilderImports = new ImportBuilder(importBuilderConfig) - const rootTypeBuilder = await TypeBuilder.fromInput( + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( "./models.ts", input, config.compilerOptions, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts index e0dea548d..7552ee522 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts @@ -3,7 +3,7 @@ import type {ServerImplementationMethod} from "../../../templates.types" import {unitTestInput} from "../../../test/input.test-utils" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {TypescriptFormatterBiome} from "../../common/typescript-formatter.biome" import {KoaRouterBuilder} from "./typescript-koa-router-builder" @@ -57,7 +57,7 @@ describe("typescript/server/typescript-koa/koa-router-builder", () => { includeFileExtensions: false, }) - const typeBuilder = await TypeBuilder.fromInput( + const typeBuilder = await TypeBuilder.fromSchemaProvider( "./unit-test.types.ts", input, {}, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts index 913ba5bbe..f8ead11e0 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts @@ -3,7 +3,7 @@ import {isDefined, titleCase} from "../../../core/utils" import type {ServerImplementationMethod} from "../../../templates.types" import type {ImportBuilder} from "../../common/import-builder" import type {SchemaBuilder} from "../../common/schema-builders/schema-builder" -import type {TypeBuilder} from "../../common/type-builder" +import type {TypeBuilder} from "../../common/type-builder/type-builder" import { constStatement, object, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa.generator.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa.generator.ts index 043f75bc2..413b71273 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa.generator.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa.generator.ts @@ -4,7 +4,7 @@ import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types" import {CompilationUnit} from "../../common/compilation-units" import {ImportBuilder} from "../../common/import-builder" import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder" -import {TypeBuilder} from "../../common/type-builder" +import {TypeBuilder} from "../../common/type-builder/type-builder" import {KoaRouterBuilder} from "./typescript-koa-router-builder" import {KoaServerBuilder} from "./typescript-koa-server-builder" @@ -20,7 +20,7 @@ export async function generateTypescriptKoa( const schemaBuilderImports = new ImportBuilder(importBuilderConfig) - const rootTypeBuilder = await TypeBuilder.fromInput( + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( "./models.ts", input, config.compilerOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dfad1a73..64edf348b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,9 +446,6 @@ importers: joi: specifier: ^18.0.2 version: 18.0.2 - piscina: - specifier: ^5.1.4 - version: 5.1.4 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -7085,10 +7082,6 @@ packages: resolution: {integrity: sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==} engines: {node: '>=20.x'} - piscina@5.1.4: - resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==} - engines: {node: '>=20.x'} - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -15738,10 +15731,6 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.1.1 - piscina@5.1.4: - optionalDependencies: - '@napi-rs/nice': 1.1.1 - pkce-challenge@5.0.1: {} pkg-dir@4.2.0: