From b9f7ca05ac31480566ecbeb2be2cc65af98e8e5a Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 11 Jul 2026 12:40:37 +0100 Subject: [PATCH 1/3] feat: typescript-json-schema --- biome.jsonc | 2 +- .../src/lib/playground/load-runtime-types.tsx | 4 +- packages/openapi-code-generator/src/cli.ts | 2 +- packages/openapi-code-generator/src/config.ts | 5 +- .../src/core/file-system/fs-adaptor.ts | 4 + .../src/core/file-system/node-fs-adaptor.ts | 24 +- .../src/core/file-system/web-fs-adaptor.ts | 17 + .../openapi-code-generator/src/core/input.ts | 12 +- .../src/core/loaders/generic.loader.ts | 2 +- .../src/core/loaders/openapi-loader.ts | 36 +- .../src/core/openapi-validator.ts | 3 + .../openapi-code-generator/src/templates.ts | 8 + .../abstract-schema-builder.ts | 10 +- .../schema-builders/joi-schema-builder.ts | 4 +- .../schema-builders/zod-v3-schema-builder.ts | 2 +- .../common/type-builder/type-builder.ts | 16 +- .../typescript-json-schema.generator.ts | 344 ++++++++++++++++++ pnpm-lock.yaml | 22 ++ scripts/generate.mjs | 13 +- 19 files changed, 498 insertions(+), 32 deletions(-) create mode 100644 packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts diff --git a/biome.jsonc b/biome.jsonc index 2278711fe..25f161f98 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -51,7 +51,7 @@ "test": "recommended" }, "rules": { - "recommended": true, + "preset": "recommended", "complexity": { "noForEach": "error" }, diff --git a/packages/documentation/src/lib/playground/load-runtime-types.tsx b/packages/documentation/src/lib/playground/load-runtime-types.tsx index 57cfd3310..bfe89f32d 100644 --- a/packages/documentation/src/lib/playground/load-runtime-types.tsx +++ b/packages/documentation/src/lib/playground/load-runtime-types.tsx @@ -20,7 +20,8 @@ export const loadRuntimeTypes = async ( | "typescript-fetch" | "typescript-axios" | "typescript-koa" - | "typescript-express", + | "typescript-express" + | "typescript-json-schema", ) => { const fileRootPath = "file:///" @@ -119,6 +120,7 @@ export const loadRuntimeTypes = async ( path: "/node_modules/@nahkies/typescript-express-runtime/joi.d.mts", }, ], + "typescript-json-schema": [], } for (const file of files[template]) { diff --git a/packages/openapi-code-generator/src/cli.ts b/packages/openapi-code-generator/src/cli.ts index afa7a8025..ac8e8abe0 100644 --- a/packages/openapi-code-generator/src/cli.ts +++ b/packages/openapi-code-generator/src/cli.ts @@ -40,7 +40,7 @@ const program = new Command() .addOption( new Option("--input-type ", "type of input specification") .env("OPENAPI_INPUT_TYPE") - .choices(["openapi3", "typespec"] as const) + .choices(["openapi3", "typespec", "json-schema"] as const) .default("openapi3" as const) .makeOptionMandatory(), ) diff --git a/packages/openapi-code-generator/src/config.ts b/packages/openapi-code-generator/src/config.ts index 1123b81dd..4f2cbb53f 100644 --- a/packages/openapi-code-generator/src/config.ts +++ b/packages/openapi-code-generator/src/config.ts @@ -8,7 +8,7 @@ import type {ServerImplementationMethod} from "./templates.types.ts" export type Config = { input: string - inputType: "openapi3" | "typespec" + inputType: "openapi3" | "typespec" | "json-schema" overrideSpecificationTitle?: string | undefined output: string template: @@ -17,6 +17,7 @@ export type Config = { | "typescript-angular" | "typescript-koa" | "typescript-express" + | "typescript-json-schema" schemaBuilder: "zod-v3" | "zod-v4" | "joi" enableRuntimeResponseValidation: boolean enableTypedBasePaths: boolean @@ -46,7 +47,7 @@ const tsServerImplementationSchema = z.enum([ export const configSchema = z.object({ input: z.string(), - inputType: z.enum(["openapi3", "typespec"]), + inputType: z.enum(["openapi3", "typespec", "json-schema"]), overrideSpecificationTitle: z.string().optional(), output: z.string(), template: templatesSchema, diff --git a/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts index 9115a663c..ac25b502b 100644 --- a/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/fs-adaptor.ts @@ -7,6 +7,10 @@ export interface IFsAdaptor { existsSync(path: string): boolean + isDir(path: string): Promise + + readDir(path: string): Promise + mkDir(path: string, recursive: boolean): Promise resolve(request: string, fromDir: string): string diff --git a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts index de15d3d20..924056921 100644 --- a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts @@ -15,8 +15,8 @@ export class NodeFsAdaptor implements IFsAdaptor { async exists(path: string) { try { - const stat = await fs.stat(path) - return stat.isFile() + await fs.stat(path) + return true } catch (err) { if ( typeof err === "object" && @@ -33,6 +33,26 @@ export class NodeFsAdaptor implements IFsAdaptor { return existsSync(path) } + async isDir(path: string) { + try { + const stat = await fs.stat(path) + return stat.isDirectory() + } catch (err) { + if ( + typeof err === "object" && + err !== null && + Reflect.get(err, "code") === "ENOENT" + ) { + return false + } + throw err + } + } + + async readDir(path: string) { + return await fs.readdir(path) + } + async mkDir(path: string, recursive = true) { await fs.mkdir(path, {recursive}) } diff --git a/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts index 9de8c0de2..c42383e81 100644 --- a/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/web-fs-adaptor.ts @@ -32,6 +32,23 @@ export class WebFsAdaptor implements IFsAdaptor { return this.files.has(path) } + async isDir(path: string) { + const prefix = path.endsWith("/") ? path : `${path}/` + return Array.from(this.files.keys()).some((it) => it.startsWith(prefix)) + } + + async readDir(path: string) { + const prefix = path.endsWith("/") ? path : `${path}/` + return Array.from( + new Set( + Array.from(this.files.keys()) + .filter((it) => it.startsWith(prefix)) + .map((it) => it.slice(prefix.length).split("/")[0]) + .filter((it): it is string => !!it), + ), + ) + } + async mkDir() { /*noop*/ } diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index b7d767458..98f113c65 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -65,10 +65,16 @@ export class Input implements ISchemaProvider { return this.normalizeServers(coalesce(this.loader.entryPoint.servers, [])) } - allSchemas(): Record { - const allDocuments = this.loader.allDocuments() + entryPoint() { + return this.loader.entryPoint + } + + allDocuments() { + return this.loader.allDocuments() + } - const schemas = allDocuments.reduce( + allSchemas(documents = this.allDocuments()): Record { + const schemas = documents.reduce( (acc, it) => { return Object.assign(acc, it.components?.schemas ?? {}) }, diff --git a/packages/openapi-code-generator/src/core/loaders/generic.loader.ts b/packages/openapi-code-generator/src/core/loaders/generic.loader.ts index 9c80a8571..b98ef3be8 100644 --- a/packages/openapi-code-generator/src/core/loaders/generic.loader.ts +++ b/packages/openapi-code-generator/src/core/loaders/generic.loader.ts @@ -28,7 +28,7 @@ export function headersForRemoteUri( export class GenericLoader { constructor( - private readonly fsAdaptor: IFsAdaptor, + readonly fsAdaptor: IFsAdaptor, private readonly requestHeaders: GenericLoaderRequestHeaders = {}, ) {} diff --git a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts index 9c216e3cf..60c07b7b0 100644 --- a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts +++ b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts @@ -194,7 +194,7 @@ export class OpenapiLoader { static async create( config: { entryPoint: string - fileType: "openapi3" | "typespec" + fileType: "openapi3" | "typespec" | "json-schema" titleOverride: string | undefined }, validator: IOpenapiValidator, @@ -224,6 +224,40 @@ export class OpenapiLoader { await loader.loadFileContent(entryPoint, openapi) break } + case "json-schema": { + const isDir = + !isRemote(entryPoint) && + (await genericLoader.fsAdaptor.isDir(entryPoint)) + + const files = isDir + ? (await genericLoader.fsAdaptor.readDir(entryPoint)).filter( + (it) => + it.endsWith(".json") || + it.endsWith(".yaml") || + it.endsWith(".yml"), + ) + : [entryPoint] + + const openapi: OpenapiDocument = { + openapi: "3.0.3", + info: { + title: config.titleOverride ?? path.basename(entryPoint), + version: "1.0.0", + }, + paths: {}, + components: { + schemas: Object.fromEntries( + files.map((file) => { + const name = path.parse(file).name + return [name, {$ref: `${path.join(entryPoint, file)}#`}] + }), + ), + }, + } + await loader.loadFileContent(entryPoint, openapi) + + break + } default: { throw new Error(`unsupported file type '${config.fileType}'`) } diff --git a/packages/openapi-code-generator/src/core/openapi-validator.ts b/packages/openapi-code-generator/src/core/openapi-validator.ts index 0fac19f89..0d0dc7885 100644 --- a/packages/openapi-code-generator/src/core/openapi-validator.ts +++ b/packages/openapi-code-generator/src/core/openapi-validator.ts @@ -15,6 +15,9 @@ export class OpenapiValidator implements IOpenapiValidator { ) {} private validationFunction(version: string): ValidateFunction { + if (version === "unknown") { + return () => true + } if (version.startsWith("3.0")) { logger.info("Validating against 3.0") return this.validate3_0 diff --git a/packages/openapi-code-generator/src/templates.ts b/packages/openapi-code-generator/src/templates.ts index 83183ca4e..065229b26 100644 --- a/packages/openapi-code-generator/src/templates.ts +++ b/packages/openapi-code-generator/src/templates.ts @@ -3,6 +3,7 @@ import type {OpenapiGenerator} from "./templates.types.ts" import {generateTypescriptAngular} from "./typescript/client/typescript-angular/typescript-angular.generator.ts" import {generateTypescriptAxios} from "./typescript/client/typescript-axios/typescript-axios.generator.ts" import {generateTypescriptFetch} from "./typescript/client/typescript-fetch/typescript-fetch.generator.ts" +import {generateTypescriptJsonSchema} from "./typescript/common/typescript-json-schema.generator.ts" import {generateTypescriptExpress} from "./typescript/server/typescript-express/typescript-express.generator.ts" import {generateTypescriptKoa} from "./typescript/server/typescript-koa/typescript-koa.generator.ts" @@ -37,6 +38,12 @@ export const templates = { run: generateTypescriptExpress, syntheticNameGenerator: defaultSyntheticNameGenerator, }, + "typescript-json-schema": { + language: "typescript", + type: "client", // Doesn't matter much for this template + run: generateTypescriptJsonSchema, + syntheticNameGenerator: defaultSyntheticNameGenerator, + }, } satisfies {[key: string]: OpenapiGenerator} export const templateNames = [ @@ -45,4 +52,5 @@ export const templateNames = [ "typescript-angular", "typescript-koa", "typescript-express", + "typescript-json-schema", ] as const satisfies Array 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 2ff0451f1..bcb2f9137 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 @@ -44,10 +44,10 @@ export abstract class AbstractSchemaBuilder< protected readonly schemaBuilderImports: ImportBuilder, typeBuilder: TypeBuilder, private readonly availableStaticSchemas: StaticSchemas, - private readonly referenced: Record = {}, - private readonly referencedStaticSchemas = new Set(), - private readonly imports?: ImportBuilder, - private readonly parent?: SubClass, + protected readonly referenced: Record = {}, + protected readonly referencedStaticSchemas = new Set(), + protected readonly imports?: ImportBuilder, + protected readonly parent?: SubClass, ) { this.graph = parent?.graph ?? @@ -60,7 +60,7 @@ export abstract class AbstractSchemaBuilder< abstract withImports(imports: ImportBuilder): SubClass - private add(reference: Reference): string { + protected add(reference: Reference): string { this.parent?.add(reference) const name = this.getSchemaNameFromRef(reference) 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 7ba8ed4dd..8e97c2984 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 @@ -24,8 +24,8 @@ import {joiIntersectRawSrc} from "./joi-runtime-snippets/joi-intersect-source.ts const joi = "joi" -const staticSchemas = {} -type StaticSchemas = typeof staticSchemas +export const staticSchemas = {} +export type StaticSchemas = typeof staticSchemas export class JoiBuilder extends AbstractSchemaBuilder< JoiBuilder, 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 23c7e47c2..10ffea703 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 @@ -39,7 +39,7 @@ export const staticSchemas = { return PermissiveBoolean.parse(value) }, ${zod}.literal(false))`, } -type StaticSchemas = typeof staticSchemas +export type StaticSchemas = typeof staticSchemas export class ZodV3Builder extends AbstractSchemaBuilder< ZodV3Builder, diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts index e9380dd77..9c3129db3 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts @@ -97,15 +97,15 @@ function toTs(type: IRType): string { } export class TypeBuilder implements ICompilable { - private constructor( + protected constructor( public readonly filename: string, - private readonly input: ISchemaProvider, - private readonly compilerOptions: CompilerOptions, - private readonly config: TypeBuilderConfig, - private readonly referenced = new Set(), - private readonly referencedStaticTypes = new Set(), - private readonly imports?: ImportBuilder, - private readonly parent?: TypeBuilder, + protected readonly input: ISchemaProvider, + protected readonly compilerOptions: CompilerOptions, + protected readonly config: TypeBuilderConfig, + protected readonly referenced = new Set(), + protected readonly referencedStaticTypes = new Set(), + protected readonly imports?: ImportBuilder, + protected readonly parent?: TypeBuilder, ) {} static async fromSchemaProvider( diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts new file mode 100644 index 000000000..ac3e50363 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts @@ -0,0 +1,344 @@ +import type {ISchemaProvider} from "../../core/input.ts" +import type {CompilerOptions} from "../../core/loaders/tsconfig.loader.ts" +import type {Reference} from "../../core/openapi-types.ts" +import {isRef} from "../../core/openapi-utils.ts" +import type {OpenapiTypescriptGeneratorConfig} from "../../templates.types.ts" +import {CompilationUnit} from "./compilation-units.ts" +import {ImportBuilder} from "./import-builder.ts" +import type { + AbstractSchemaBuilder, + SchemaBuilderConfig, +} from "./schema-builders/abstract-schema-builder.ts" +import { + JoiBuilder, + staticSchemas as joiStaticSchemas, +} from "./schema-builders/joi-schema-builder.ts" +import { + ZodV3Builder, + staticSchemas as zodV3StaticSchemas, +} from "./schema-builders/zod-v3-schema-builder.ts" +import { + ZodV4Builder, + staticSchemas as zodV4StaticSchemas, +} from "./schema-builders/zod-v4-schema-builder.ts" +import { + TypeBuilder, + type TypeBuilderConfig, +} from "./type-builder/type-builder.ts" + +class JsonSchemaTypeBuilder extends TypeBuilder { + constructor( + filename: string, + input: ISchemaProvider, + compilerOptions: CompilerOptions, + config: TypeBuilderConfig, + private readonly refToFilename: Record, + imports?: ImportBuilder, + parent?: TypeBuilder, + ) { + super( + filename, + input, + compilerOptions, + config, + new Set(), + new Set(), + imports, + parent, + ) + } + + protected override add(reference: Reference): string { + const name = this.getTypeNameFromRef(reference) + const ref = reference.$ref.split("#")[0] + + if ( + ref && + this.refToFilename[ref] && + this.refToFilename[ref] !== this.filename + ) { + this.imports?.addSingle(name, this.refToFilename[ref], true) + return name + } + + return super.add(reference) + } + + override withImports(imports: ImportBuilder): JsonSchemaTypeBuilder { + return new JsonSchemaTypeBuilder( + this.filename, + this.input, + this.compilerOptions, + this.config, + this.refToFilename, + imports, + this, + ) + } +} + +class JsonSchemaZodV4Builder extends ZodV4Builder { + constructor( + filename: string, + schemaProvider: ISchemaProvider, + config: SchemaBuilderConfig, + schemaBuilderImports: ImportBuilder, + typeBuilder: TypeBuilder, + private readonly refToFilename: Record, + imports?: ImportBuilder, + ) { + super( + filename, + schemaProvider, + config, + schemaBuilderImports, + typeBuilder, + zodV4StaticSchemas, + {}, + new Set(), + imports, + ) + } + + protected override add(reference: Reference): string { + const name = this.getSchemaNameFromRef(reference) + const ref = reference.$ref.split("#")[0] + + if ( + ref && + this.refToFilename[ref] && + this.refToFilename[ref] !== this.filename + ) { + this.imports?.addSingle(name, this.refToFilename[ref], false) + return name + } + + return super.add(reference) + } + + override withImports(imports: ImportBuilder): ZodV4Builder { + return new JsonSchemaZodV4Builder( + this.filename, + this.schemaProvider, + this.config, + this.schemaBuilderImports, + this.typeBuilder, + this.refToFilename, + imports, + ) + } +} + +class JsonSchemaZodV3Builder extends ZodV3Builder { + constructor( + filename: string, + schemaProvider: ISchemaProvider, + config: SchemaBuilderConfig, + schemaBuilderImports: ImportBuilder, + typeBuilder: TypeBuilder, + private readonly refToFilename: Record, + imports?: ImportBuilder, + ) { + super( + filename, + schemaProvider, + config, + schemaBuilderImports, + typeBuilder, + zodV3StaticSchemas, + {}, + new Set(), + imports, + ) + } + + protected override add(reference: Reference): string { + const name = this.getSchemaNameFromRef(reference) + const ref = reference.$ref.split("#")[0] + + if ( + ref && + this.refToFilename[ref] && + this.refToFilename[ref] !== this.filename + ) { + this.imports?.addSingle(name, this.refToFilename[ref], false) + return name + } + + return super.add(reference) + } + + override withImports(imports: ImportBuilder): ZodV3Builder { + return new JsonSchemaZodV3Builder( + this.filename, + this.schemaProvider, + this.config, + this.schemaBuilderImports, + this.typeBuilder, + this.refToFilename, + imports, + ) + } +} + +class JsonSchemaJoiBuilder extends JoiBuilder { + constructor( + filename: string, + schemaProvider: ISchemaProvider, + config: SchemaBuilderConfig, + schemaBuilderImports: ImportBuilder, + typeBuilder: TypeBuilder, + private readonly refToFilename: Record, + imports?: ImportBuilder, + ) { + super( + filename, + schemaProvider, + config, + schemaBuilderImports, + typeBuilder, + joiStaticSchemas, + {}, + new Set(), + imports, + ) + } + + protected override add(reference: Reference): string { + const name = this.getSchemaNameFromRef(reference) + const ref = reference.$ref.split("#")[0] + + if ( + ref && + this.refToFilename[ref] && + this.refToFilename[ref] !== this.filename + ) { + this.imports?.addSingle(name, this.refToFilename[ref], false) + return name + } + + return super.add(reference) + } + + override withImports(imports: ImportBuilder): JoiBuilder { + return new JsonSchemaJoiBuilder( + this.filename, + this.schemaProvider, + this.config, + this.schemaBuilderImports, + this.typeBuilder, + this.refToFilename, + imports, + ) + } +} + +export async function generateTypescriptJsonSchema( + config: OpenapiTypescriptGeneratorConfig, +): Promise { + const {input, emitter, allowAny} = config + const importBuilderConfig = {includeFileExtensions: config.isEsmProject} + + // We need to know which file each schema came from. + // The input.loader has this information. + // Since we know we are using the synthetic OpenAPI doc for multiple files, + // or a single file if input-type is json-schema. + + const allSchemas = input.allSchemas([input.entryPoint()]) + const units: CompilationUnit[] = [] + + const refToFilename: Record = {} + + // Hacky way to get the ref for each top-level schema. + // In our synthetic OpenAPI, they were created as: + // name -> { $ref: 'full/path/to/file.json#' } + + const entryPoint = input.entryPoint() + if (entryPoint.components?.schemas) { + for (const [name, schema] of Object.entries( + entryPoint.components.schemas, + )) { + if (isRef(schema)) { + const ref = schema.$ref.split("#")[0] + if (ref) { + refToFilename[ref] = `./${name}.ts` + } + } + } + } + + for (const [name, schema] of Object.entries(allSchemas)) { + const filename = `${name}.ts` + const imports = new ImportBuilder({ + ...importBuilderConfig, + unit: {filename}, + }) + + const typeBuilder = new JsonSchemaTypeBuilder( + filename, + input, + config.compilerOptions, + {allowAny}, + refToFilename, + imports, + ) + + // biome-ignore lint/suspicious/noExplicitAny: generics + let schemaBuilder: AbstractSchemaBuilder + if (config.schemaBuilder === "zod-v4") { + schemaBuilder = new JsonSchemaZodV4Builder( + filename, + input, + {allowAny}, + imports, + typeBuilder, + refToFilename, + imports, + ) + } else if (config.schemaBuilder === "zod-v3") { + schemaBuilder = new JsonSchemaZodV3Builder( + filename, + input, + {allowAny}, + imports, + typeBuilder, + refToFilename, + imports, + ) + } else if (config.schemaBuilder === "joi") { + schemaBuilder = new JsonSchemaJoiBuilder( + filename, + input, + {allowAny}, + imports, + typeBuilder, + refToFilename, + imports, + ) + } else { + throw new Error(`Unknown schema builder: ${config.schemaBuilder}`) + } + + const typeName = typeBuilder.getTypeNameFromRef({ + $ref: `#/components/schemas/${name}`, + }) + const schemaName = schemaBuilder.getSchemaNameFromRef({ + $ref: `#/components/schemas/${name}`, + }) + + const typeCode = typeBuilder.schemaObjectToType(schema) + const schemaCode = schemaBuilder.fromModel(schema, true) + + const typeExport = `export type ${typeName} = ${typeCode}` + const schemaExport = `export const ${schemaName} = ${schemaCode}` + + const unit = new CompilationUnit( + filename, + imports, + [typeExport, schemaExport].join("\n\n"), + ) + units.push(unit) + } + + await emitter.emitGenerationResult(units) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d24c93f8e..a7dc64a43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,28 @@ importers: specifier: 'catalog:' version: 6.0.3 + integration-tests/typescript-json-schema: + dependencies: + '@nahkies/typescript-fetch-runtime': + specifier: workspace:* + version: link:../../packages/typescript-fetch-runtime + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + typescript: + specifier: 'catalog:' + version: 6.0.3 + integration-tests/typescript-koa: dependencies: '@koa/router': diff --git a/scripts/generate.mjs b/scripts/generate.mjs index 72ee88975..b09d1ee70 100755 --- a/scripts/generate.mjs +++ b/scripts/generate.mjs @@ -26,7 +26,7 @@ const templates = execSync( .map((it) => it.trim()) .filter(Boolean) -const definitions = execSync("find ./integration-tests-definitions -type f") +const definitions = execSync("find ./integration-tests-definitions -type f -maxdepth 1") .toString("utf-8") .split("\n") .map((it) => it.trim()) @@ -42,14 +42,16 @@ const filteredSpec = config.spec ? path.normalize(config.spec) : undefined console.info("filters", {filteredTemplate, filteredSpec}) Promise.all( templates - .filter((it) => !filteredTemplate || it.includes(filteredTemplate)) + .filter((it) => !it.includes('typescript-json-schema') && !filteredTemplate || it.includes(filteredTemplate)) .flatMap((templatePath) => definitions .filter( (it) => !filteredSpec || path.normalize(it).includes(filteredSpec), ) .map((definition) => runSingle(templatePath, definition)), - ), + ).concat( + runSingle("./integration-tests/typescript-json-schema", "./integration-tests-definitions/json-schemas"), + ) ) .then(() => { console.log("success!") @@ -61,7 +63,10 @@ Promise.all( }) async function runSingle(templatePath, input) { - const inputType = input.endsWith(".tsp") ? "typespec" : "openapi3" + let inputType = input.endsWith(".tsp") ? "typespec" : "openapi3" + if (input.includes("json-schemas")) { + inputType = "json-schema" + } const filename = path.basename(input) const template = path.basename(templatePath) From 602cc47b4d6c4974f1945a5ee9c7f3dc18e51c44 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 11 Jul 2026 12:41:30 +0100 Subject: [PATCH 2/3] test --- .../json-schemas/address.json | 8 +++++++ .../json-schemas/user.json | 8 +++++++ .../typescript-json-schema/package.json | 23 +++++++++++++++++++ .../src/generated/json-schemas/address.ts | 12 ++++++++++ .../src/generated/json-schemas/user.ts | 16 +++++++++++++ .../typescript-json-schema/tsconfig.json | 13 +++++++++++ scripts/generate.mjs | 20 ++++++++++++---- 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 integration-tests-definitions/json-schemas/address.json create mode 100644 integration-tests-definitions/json-schemas/user.json create mode 100644 integration-tests/typescript-json-schema/package.json create mode 100644 integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts create mode 100644 integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts create mode 100644 integration-tests/typescript-json-schema/tsconfig.json diff --git a/integration-tests-definitions/json-schemas/address.json b/integration-tests-definitions/json-schemas/address.json new file mode 100644 index 000000000..6a8d62f52 --- /dev/null +++ b/integration-tests-definitions/json-schemas/address.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + }, + "required": ["street", "city"] +} diff --git a/integration-tests-definitions/json-schemas/user.json b/integration-tests-definitions/json-schemas/user.json new file mode 100644 index 000000000..e9bd44801 --- /dev/null +++ b/integration-tests-definitions/json-schemas/user.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "./address.json" } + }, + "required": ["name"] +} diff --git a/integration-tests/typescript-json-schema/package.json b/integration-tests/typescript-json-schema/package.json new file mode 100644 index 000000000..8e15bdf21 --- /dev/null +++ b/integration-tests/typescript-json-schema/package.json @@ -0,0 +1,23 @@ +{ + "name": "@integration/typescript-json-schema", + "version": "0.0.1", + "main": "./dist/index.js", + "author": "Michael Nahkies", + "license": "MIT", + "private": true, + "scripts": { + "clean": "rm -rf ./dist && rm -rf ./src/generated", + "validate": "tsc -p ./tsconfig.json" + }, + "type": "module", + "dependencies": { + "@nahkies/typescript-fetch-runtime": "workspace:*", + "dotenv": "^17.4.2", + "tslib": "^2.8.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "catalog:" + } +} diff --git a/integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts b/integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts new file mode 100644 index 000000000..40b511ae6 --- /dev/null +++ b/integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts @@ -0,0 +1,12 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import {z} from "zod/v4" + +export type t_address = { + city: string + street: string +} + +export const s_address = z.object({street: z.string(), city: z.string()}) diff --git a/integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts b/integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts new file mode 100644 index 000000000..cff4626b2 --- /dev/null +++ b/integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts @@ -0,0 +1,16 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import {s_address_json, type t_address_json} from "user.ts" +import {z} from "zod/v4" + +export type t_user = { + address?: t_address_json + name: string +} + +export const s_user = z.object({ + name: z.string(), + address: s_address_json.optional(), +}) diff --git a/integration-tests/typescript-json-schema/tsconfig.json b/integration-tests/typescript-json-schema/tsconfig.json new file mode 100644 index 000000000..eea5bf44e --- /dev/null +++ b/integration-tests/typescript-json-schema/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "outDir": "./dist", + "rootDir": "./src", + /* exercise the code path where exactOptionalPropertyTypes is disabled */ + "exactOptionalPropertyTypes": false, + "noUnusedLocals": true, + "verbatimModuleSyntax": true, + "rewriteRelativeImportExtensions": true + } +} diff --git a/scripts/generate.mjs b/scripts/generate.mjs index b09d1ee70..ebb75ff82 100755 --- a/scripts/generate.mjs +++ b/scripts/generate.mjs @@ -26,7 +26,9 @@ const templates = execSync( .map((it) => it.trim()) .filter(Boolean) -const definitions = execSync("find ./integration-tests-definitions -type f -maxdepth 1") +const definitions = execSync( + "find ./integration-tests-definitions -type f -maxdepth 1", +) .toString("utf-8") .split("\n") .map((it) => it.trim()) @@ -42,16 +44,24 @@ const filteredSpec = config.spec ? path.normalize(config.spec) : undefined console.info("filters", {filteredTemplate, filteredSpec}) Promise.all( templates - .filter((it) => !it.includes('typescript-json-schema') && !filteredTemplate || it.includes(filteredTemplate)) + .filter( + (it) => + (!it.includes("typescript-json-schema") && !filteredTemplate) || + it.includes(filteredTemplate), + ) .flatMap((templatePath) => definitions .filter( (it) => !filteredSpec || path.normalize(it).includes(filteredSpec), ) .map((definition) => runSingle(templatePath, definition)), - ).concat( - runSingle("./integration-tests/typescript-json-schema", "./integration-tests-definitions/json-schemas"), - ) + ) + .concat( + runSingle( + "./integration-tests/typescript-json-schema", + "./integration-tests-definitions/json-schemas", + ), + ), ) .then(() => { console.log("success!") From b1e93de7cbd90f5ede92e241a5586f7847efafa5 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 11 Jul 2026 15:20:53 +0100 Subject: [PATCH 3/3] wip: kinda works --- .../json-schemas/user.json | 3 +- .../{json-schemas => json-schema}/address.ts | 0 .../{json-schemas => json-schema}/user.ts | 10 +- .../src/core/dependency-graph.ts | 8 +- .../src/core/file-system/node-fs-adaptor.ts | 3 +- .../openapi-code-generator/src/core/input.ts | 8 + .../src/core/loaders/openapi-loader.ts | 55 ++- .../src/core/openapi-utils.ts | 5 + .../src/core/openapi-validator.ts | 10 +- .../src/test/fake-schema-provider.ts | 13 + .../typescript/common/import-builder.spec.ts | 13 + .../src/typescript/common/import-builder.ts | 4 + .../abstract-schema-builder.ts | 22 +- .../schema-builders/joi-schema-builder.ts | 6 +- .../schema-builders/zod-v3-schema-builder.ts | 9 +- .../schema-builders/zod-v4-schema-builder.ts | 10 +- .../common/type-builder/type-builder.ts | 15 +- .../typescript-json-schema.generator.ts | 326 ++---------------- ...escript-json-schema.generator.unit.spec.ts | 103 ++++++ 19 files changed, 280 insertions(+), 343 deletions(-) rename integration-tests/typescript-json-schema/src/generated/{json-schemas => json-schema}/address.ts (100%) rename integration-tests/typescript-json-schema/src/generated/{json-schemas => json-schema}/user.ts (53%) create mode 100644 packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.unit.spec.ts diff --git a/integration-tests-definitions/json-schemas/user.json b/integration-tests-definitions/json-schemas/user.json index e9bd44801..fe11fffbc 100644 --- a/integration-tests-definitions/json-schemas/user.json +++ b/integration-tests-definitions/json-schemas/user.json @@ -2,7 +2,8 @@ "type": "object", "properties": { "name": { "type": "string" }, - "address": { "$ref": "./address.json" } + "address": { "$ref": "./address.json" }, + "self": { "$ref": "./user.json" } }, "required": ["name"] } diff --git a/integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts b/integration-tests/typescript-json-schema/src/generated/json-schema/address.ts similarity index 100% rename from integration-tests/typescript-json-schema/src/generated/json-schemas/address.ts rename to integration-tests/typescript-json-schema/src/generated/json-schema/address.ts diff --git a/integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts b/integration-tests/typescript-json-schema/src/generated/json-schema/user.ts similarity index 53% rename from integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts rename to integration-tests/typescript-json-schema/src/generated/json-schema/user.ts index cff4626b2..5e7470338 100644 --- a/integration-tests/typescript-json-schema/src/generated/json-schemas/user.ts +++ b/integration-tests/typescript-json-schema/src/generated/json-schema/user.ts @@ -2,15 +2,19 @@ /* tslint:disable */ /* eslint-disable */ -import {s_address_json, type t_address_json} from "user.ts" import {z} from "zod/v4" +import {s_address, type t_address} from "./address.ts" export type t_user = { - address?: t_address_json + address?: t_address name: string + self?: t_user } export const s_user = z.object({ name: z.string(), - address: s_address_json.optional(), + address: s_address.optional(), + get self() { + return s_user.optional() + }, }) diff --git a/packages/openapi-code-generator/src/core/dependency-graph.ts b/packages/openapi-code-generator/src/core/dependency-graph.ts index f5bff5291..58854b96e 100644 --- a/packages/openapi-code-generator/src/core/dependency-graph.ts +++ b/packages/openapi-code-generator/src/core/dependency-graph.ts @@ -110,7 +110,13 @@ export function buildDependencyGraph( const order: string[] = [] // TODO: this may miss extracted in-line schemas - for (const [name, schema] of Object.entries(schemaProvider.allSchemas())) { + for (const [name, schema] of Object.entries( + schemaProvider.allSchemas(), + ).concat( + schemaProvider + .allJsonSchemaDocuments() + .map((it) => [it.filename, it.schema]), + )) { remaining.set( getNameForRef({$ref: name}), getDependenciesFromSchema(schema, getNameForRef), diff --git a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts index 924056921..87335b3ab 100644 --- a/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts +++ b/packages/openapi-code-generator/src/core/file-system/node-fs-adaptor.ts @@ -50,7 +50,8 @@ export class NodeFsAdaptor implements IFsAdaptor { } async readDir(path: string) { - return await fs.readdir(path) + const files = await fs.readdir(path, {recursive: true}) + return files.map((file) => pathModule.join(path, file)) } async mkDir(path: string, recursive = true) { diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 98f113c65..cc2cd62ce 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -40,6 +40,7 @@ export type InputConfig = { export interface ISchemaProvider { schema(maybeRef: MaybeIRModel | Reference): IRModel allSchemas(): Record + allJsonSchemaDocuments(): {filename: string; schema: IRModel}[] preprocess(maybePreprocess: Reference | xInternalPreproccess): IRPreprocess } @@ -73,6 +74,13 @@ export class Input implements ISchemaProvider { return this.loader.allDocuments() } + allJsonSchemaDocuments(): {filename: string; schema: IRModel}[] { + return this.loader.allJsonSchemas().map((it) => ({ + filename: it.filename, + schema: this.schemaNormalizer.normalize(it.schema), + })) + } + allSchemas(documents = this.allDocuments()): Record { const schemas = documents.reduce( (acc, it) => { diff --git a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts index 60c07b7b0..3ba65623f 100644 --- a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts +++ b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts @@ -21,6 +21,7 @@ import {isRemote} from "./utils.ts" export class OpenapiLoader { private readonly virtualLibrary = new Map() private readonly library = new Map() + private readonly jsonSchemas = new Map() private constructor( private readonly entryPointKey: string, @@ -56,6 +57,13 @@ export class OpenapiLoader { return Array.from(this.library.values()) } + allJsonSchemas(): {filename: string; schema: Schema}[] { + return Array.from(this.jsonSchemas.entries()).map(([key, value]) => ({ + filename: key, + schema: value, + })) + } + paths(maybeRef: Reference | Path): Path { return isRef(maybeRef) ? this.$ref(maybeRef) : maybeRef } @@ -88,7 +96,7 @@ export class OpenapiLoader { const [key, objPath] = $ref.split("#") // biome-ignore lint/suspicious/noExplicitAny: dynamic - const obj: any = key && this.library.get(key) + const obj: any = key && (this.library.get(key) ?? this.jsonSchemas.get(key)) if (!obj) { throw new Error(`could not load $ref, key not loaded. $ref: '${$ref}'`) @@ -148,6 +156,15 @@ export class OpenapiLoader { } } + private async loadJsonSchema(file: string) { + if (this.jsonSchemas.has(file)) { + return + } + const [loadedFrom, definition] = await this.genericLoader.loadFile(file) + this.jsonSchemas.set(loadedFrom, definition as any) + await this.normalizeRefs(loadedFrom, definition) + } + // biome-ignore lint/suspicious/noExplicitAny: dynamic private async normalizeRefs(loadedFrom: string, obj: any) { for (const key in obj) { @@ -238,23 +255,27 @@ export class OpenapiLoader { ) : [entryPoint] - const openapi: OpenapiDocument = { - openapi: "3.0.3", - info: { - title: config.titleOverride ?? path.basename(entryPoint), - version: "1.0.0", - }, - paths: {}, - components: { - schemas: Object.fromEntries( - files.map((file) => { - const name = path.parse(file).name - return [name, {$ref: `${path.join(entryPoint, file)}#`}] - }), - ), - }, + for (const file of files) { + await loader.loadJsonSchema(file) } - await loader.loadFileContent(entryPoint, openapi) + // + // const openapi: OpenapiDocument = { + // openapi: "3.0.3", + // info: { + // title: config.titleOverride ?? path.basename(entryPoint), + // version: "1.0.0", + // }, + // paths: {}, + // components: { + // schemas: Object.fromEntries( + // files.map((file) => { + // const name = path.parse(file).name + // return [name, {$ref: `${path.join(entryPoint, file)}#`}] + // }), + // ), + // }, + // } + // await loader.loadFileContent(entryPoint, openapi) break } diff --git a/packages/openapi-code-generator/src/core/openapi-utils.ts b/packages/openapi-code-generator/src/core/openapi-utils.ts index 7d9e1f8c8..ce4ad6e4a 100644 --- a/packages/openapi-code-generator/src/core/openapi-utils.ts +++ b/packages/openapi-code-generator/src/core/openapi-utils.ts @@ -1,3 +1,4 @@ +import path from "node:path" import type {Reference} from "./openapi-types.ts" export function isRef(it: unknown | Reference): it is Reference { @@ -8,6 +9,10 @@ export function isRef(it: unknown | Reference): it is Reference { } export function getRawNameFromRef({$ref}: Reference): string { + if (!$ref.includes("#")) { + return path.basename($ref, path.extname($ref)) + } + const name = $ref.split("/").pop() if (!name) { diff --git a/packages/openapi-code-generator/src/core/openapi-validator.ts b/packages/openapi-code-generator/src/core/openapi-validator.ts index 0d0dc7885..d18613fca 100644 --- a/packages/openapi-code-generator/src/core/openapi-validator.ts +++ b/packages/openapi-code-generator/src/core/openapi-validator.ts @@ -1,7 +1,7 @@ import {logger} from "./logger.ts" import type {ValidateFunction} from "./schemas/IValidateFunction.ts" -import validate3_0 from "./schemas/openapi-3.0-specification-validator.ts" -import validate3_1 from "./schemas/openapi-3.1-specification-validator.ts" +// import validate3_0 from "./schemas/openapi-3.0-specification-validator.ts" +// import validate3_1 from "./schemas/openapi-3.1-specification-validator.ts" export interface IOpenapiValidator { validate(filename: string, schema: unknown, strict?: boolean): Promise @@ -84,7 +84,11 @@ export class OpenapiValidator implements IOpenapiValidator { } try { - return new OpenapiValidator(validate3_1, validate3_0, onValidationFailed) + return new OpenapiValidator( + () => true, + () => true, + onValidationFailed, + ) } catch (err) { logger.warn( "Skipping validation as failed to load schema specification", diff --git a/packages/openapi-code-generator/src/test/fake-schema-provider.ts b/packages/openapi-code-generator/src/test/fake-schema-provider.ts index 3bbe39130..f59a1f8d8 100644 --- a/packages/openapi-code-generator/src/test/fake-schema-provider.ts +++ b/packages/openapi-code-generator/src/test/fake-schema-provider.ts @@ -9,11 +9,16 @@ import {getRawNameFromRef, isRef} from "../core/openapi-utils.ts" export class FakeSchemaProvider implements ISchemaProvider { private readonly testRefs: Record = {} + private readonly testJsonSchemaDocuments: Record = {} registerTestRef(ref: IRRef, model: IRModel) { this.testRefs[ref.$ref] = model } + registerJsonSchemaDocument(filename: string, schema: IRModel) { + this.testJsonSchemaDocuments[filename] = schema + } + schema(maybeRef: MaybeIRModel): IRModel { if (isRef(maybeRef)) { const result = this.testRefs[maybeRef.$ref] @@ -38,6 +43,14 @@ export class FakeSchemaProvider implements ISchemaProvider { ) } + allJsonSchemaDocuments() { + return Object.entries(this.testJsonSchemaDocuments).map( + ([filename, schema]) => { + return {filename, schema} + }, + ) + } + preprocess(): IRPreprocess { return {} } diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts index 9aa537570..714a7d372 100644 --- a/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.spec.ts @@ -89,6 +89,19 @@ describe("typescript/common/import-builder", () => { expect(builder.toString()).toBe("import {Cat} from '../bar/models'") }) + it("does not import from itself", () => { + const builder = new ImportBuilder({ + unit: { + filename: "./foo/example.ts", + }, + includeFileExtensions: false, + }) + + builder.addSingle("Self", "./foo/example.ts", false) + + expect(builder.toString()).toBe("") + }) + it("handles absolute paths", () => { const builder = new ImportBuilder({ unit: { diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.ts index 1566131c4..79a157be4 100644 --- a/packages/openapi-code-generator/src/typescript/common/import-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.ts @@ -205,6 +205,10 @@ export class ImportBuilder { // biome-ignore lint/style/noParameterAssign: normalization from = this.normalizeFrom(from) + if (this.config.unit?.filename === from) { + throw new Error(`cannot import from self: ${from}`) + } + if (!this.imports[from]) { this.imports[from] = { values: new Set(), 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 bcb2f9137..ebb1ce361 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 @@ -24,6 +24,7 @@ import type {SchemaBuilderType} from "./schema-builder.ts" export type SchemaBuilderConfig = { allowAny: boolean + refToFilename?: ($ref: string) => string } export abstract class AbstractSchemaBuilder< @@ -33,7 +34,7 @@ export abstract class AbstractSchemaBuilder< { public abstract readonly type: SchemaBuilderType - private readonly graph: DependencyGraph + protected readonly graph: DependencyGraph protected readonly typeBuilder: TypeBuilder @@ -67,7 +68,10 @@ export abstract class AbstractSchemaBuilder< this.referenced[name] = reference if (this.imports) { - this.imports.addSingle(name, this.filename, false) + const from = this.config.refToFilename?.(reference.$ref) ?? this.filename + if (from !== this.filename) { + this.imports.addSingle(name, from, false) + } } return name @@ -204,9 +208,9 @@ export abstract class AbstractSchemaBuilder< result = required ? this.required(result, false) : this.optional(result) - if (this.graph.circular.has(name) && !isAnonymous) { - return this.lazy(result) - } + // if (this.graph.circular.has(name) && !isAnonymous) { + // return this.lazy(result) + // } return result } @@ -287,7 +291,11 @@ export abstract class AbstractSchemaBuilder< Object.entries(model.properties).map(([key, value]) => { return [ key, - this.fromModel(value, model.required.includes(key)), + { + schema: this.fromModel(value, model.required.includes(key)), + isLazy: + isRef(value) && this.graph.circular.has(this.add(value)), + }, ] }), ), @@ -384,7 +392,7 @@ export abstract class AbstractSchemaBuilder< protected abstract required(schema: string, hasDefaultValue: boolean): string protected abstract object( - keys: Record, + keys: Record, required: boolean, ): string 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 8e97c2984..ccfc24077 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 @@ -214,14 +214,16 @@ export class JoiBuilder extends AbstractSchemaBuilder< return [schema, "required()"].join(".") } - protected object(keys: Record): string { + protected object( + keys: Record, + ): string { const entries = Object.entries(keys) return [ joi, "object()", `keys({${entries - .map(([key, value]) => `"${key}": ${value}`) + .map(([key, value]) => `"${key}": ${value.schema}`) .join(",")} })`, "options({ stripUnknown: true })", ] 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 10ffea703..ffd9ba088 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 @@ -202,13 +202,18 @@ export class ZodV3Builder extends AbstractSchemaBuilder< return schema } - protected object(keys: Record): string { + protected object( + keys: Record, + ): string { const entries = Object.entries(keys) return [ zod, `object({${entries - .map(([key, value]) => `"${key}": ${value}`) + .map( + ([key, value]) => + `"${key}": ${value.isLazy ? this.lazy(value.schema) : value.schema}`, + ) .join(",")}})`, ] .filter(isDefined) 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 eed7f5f30..80e666754 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 @@ -227,13 +227,19 @@ export class ZodV4Builder extends AbstractSchemaBuilder< return schema } - protected object(keys: Record): string { + protected object( + keys: Record, + ): string { const entries = Object.entries(keys) return [ zod, `object({${entries - .map(([key, value]) => `"${key}": ${value}`) + .map(([key, value]) => { + return value.isLazy + ? `get ${key}() { return ${value.schema} }` + : `"${key}": ${value.schema}` + }) .join(",")}})`, ] .filter(isDefined) diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts index 9c3129db3..d0a465b27 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts @@ -32,6 +32,7 @@ type StaticType = keyof typeof staticTypes export type TypeBuilderConfig = { allowAny: boolean + refToFilename?: ($ref: string) => string } type IRTypeIntersection = {type: "type-intersection"; types: IRType[]} @@ -139,13 +140,19 @@ export class TypeBuilder implements ICompilable { return getNameFromRef(reference, "t_") } - protected add({$ref}: Reference): string { - this.parent?.add({$ref}) + protected add(reference: Reference): string { + const {$ref} = reference + this.parent?.add(reference) this.referenced.add($ref) - const name = this.getTypeNameFromRef({$ref}) + const name = this.getTypeNameFromRef(reference) - this.imports?.addSingle(name, this.filename, true) + const ref = $ref.split("#")[0] + const from = (ref && this.config.refToFilename?.(ref)) || this.filename + + if (from !== this.filename) { + this.imports?.addSingle(name, from, true) + } return name } diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts index ac3e50363..58af2a89d 100644 --- a/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts +++ b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.ts @@ -1,237 +1,9 @@ -import type {ISchemaProvider} from "../../core/input.ts" -import type {CompilerOptions} from "../../core/loaders/tsconfig.loader.ts" -import type {Reference} from "../../core/openapi-types.ts" -import {isRef} from "../../core/openapi-utils.ts" +import path from "node:path" import type {OpenapiTypescriptGeneratorConfig} from "../../templates.types.ts" import {CompilationUnit} from "./compilation-units.ts" import {ImportBuilder} from "./import-builder.ts" -import type { - AbstractSchemaBuilder, - SchemaBuilderConfig, -} from "./schema-builders/abstract-schema-builder.ts" -import { - JoiBuilder, - staticSchemas as joiStaticSchemas, -} from "./schema-builders/joi-schema-builder.ts" -import { - ZodV3Builder, - staticSchemas as zodV3StaticSchemas, -} from "./schema-builders/zod-v3-schema-builder.ts" -import { - ZodV4Builder, - staticSchemas as zodV4StaticSchemas, -} from "./schema-builders/zod-v4-schema-builder.ts" -import { - TypeBuilder, - type TypeBuilderConfig, -} from "./type-builder/type-builder.ts" - -class JsonSchemaTypeBuilder extends TypeBuilder { - constructor( - filename: string, - input: ISchemaProvider, - compilerOptions: CompilerOptions, - config: TypeBuilderConfig, - private readonly refToFilename: Record, - imports?: ImportBuilder, - parent?: TypeBuilder, - ) { - super( - filename, - input, - compilerOptions, - config, - new Set(), - new Set(), - imports, - parent, - ) - } - - protected override add(reference: Reference): string { - const name = this.getTypeNameFromRef(reference) - const ref = reference.$ref.split("#")[0] - - if ( - ref && - this.refToFilename[ref] && - this.refToFilename[ref] !== this.filename - ) { - this.imports?.addSingle(name, this.refToFilename[ref], true) - return name - } - - return super.add(reference) - } - - override withImports(imports: ImportBuilder): JsonSchemaTypeBuilder { - return new JsonSchemaTypeBuilder( - this.filename, - this.input, - this.compilerOptions, - this.config, - this.refToFilename, - imports, - this, - ) - } -} - -class JsonSchemaZodV4Builder extends ZodV4Builder { - constructor( - filename: string, - schemaProvider: ISchemaProvider, - config: SchemaBuilderConfig, - schemaBuilderImports: ImportBuilder, - typeBuilder: TypeBuilder, - private readonly refToFilename: Record, - imports?: ImportBuilder, - ) { - super( - filename, - schemaProvider, - config, - schemaBuilderImports, - typeBuilder, - zodV4StaticSchemas, - {}, - new Set(), - imports, - ) - } - - protected override add(reference: Reference): string { - const name = this.getSchemaNameFromRef(reference) - const ref = reference.$ref.split("#")[0] - - if ( - ref && - this.refToFilename[ref] && - this.refToFilename[ref] !== this.filename - ) { - this.imports?.addSingle(name, this.refToFilename[ref], false) - return name - } - - return super.add(reference) - } - - override withImports(imports: ImportBuilder): ZodV4Builder { - return new JsonSchemaZodV4Builder( - this.filename, - this.schemaProvider, - this.config, - this.schemaBuilderImports, - this.typeBuilder, - this.refToFilename, - imports, - ) - } -} - -class JsonSchemaZodV3Builder extends ZodV3Builder { - constructor( - filename: string, - schemaProvider: ISchemaProvider, - config: SchemaBuilderConfig, - schemaBuilderImports: ImportBuilder, - typeBuilder: TypeBuilder, - private readonly refToFilename: Record, - imports?: ImportBuilder, - ) { - super( - filename, - schemaProvider, - config, - schemaBuilderImports, - typeBuilder, - zodV3StaticSchemas, - {}, - new Set(), - imports, - ) - } - - protected override add(reference: Reference): string { - const name = this.getSchemaNameFromRef(reference) - const ref = reference.$ref.split("#")[0] - - if ( - ref && - this.refToFilename[ref] && - this.refToFilename[ref] !== this.filename - ) { - this.imports?.addSingle(name, this.refToFilename[ref], false) - return name - } - - return super.add(reference) - } - - override withImports(imports: ImportBuilder): ZodV3Builder { - return new JsonSchemaZodV3Builder( - this.filename, - this.schemaProvider, - this.config, - this.schemaBuilderImports, - this.typeBuilder, - this.refToFilename, - imports, - ) - } -} - -class JsonSchemaJoiBuilder extends JoiBuilder { - constructor( - filename: string, - schemaProvider: ISchemaProvider, - config: SchemaBuilderConfig, - schemaBuilderImports: ImportBuilder, - typeBuilder: TypeBuilder, - private readonly refToFilename: Record, - imports?: ImportBuilder, - ) { - super( - filename, - schemaProvider, - config, - schemaBuilderImports, - typeBuilder, - joiStaticSchemas, - {}, - new Set(), - imports, - ) - } - - protected override add(reference: Reference): string { - const name = this.getSchemaNameFromRef(reference) - const ref = reference.$ref.split("#")[0] - - if ( - ref && - this.refToFilename[ref] && - this.refToFilename[ref] !== this.filename - ) { - this.imports?.addSingle(name, this.refToFilename[ref], false) - return name - } - - return super.add(reference) - } - - override withImports(imports: ImportBuilder): JoiBuilder { - return new JsonSchemaJoiBuilder( - this.filename, - this.schemaProvider, - this.config, - this.schemaBuilderImports, - this.typeBuilder, - this.refToFilename, - imports, - ) - } -} +import {schemaBuilderFactory} from "./schema-builders/schema-builder.ts" +import {TypeBuilder} from "./type-builder/type-builder.ts" export async function generateTypescriptJsonSchema( config: OpenapiTypescriptGeneratorConfig, @@ -239,95 +11,49 @@ export async function generateTypescriptJsonSchema( const {input, emitter, allowAny} = config const importBuilderConfig = {includeFileExtensions: config.isEsmProject} - // We need to know which file each schema came from. - // The input.loader has this information. - // Since we know we are using the synthetic OpenAPI doc for multiple files, - // or a single file if input-type is json-schema. - - const allSchemas = input.allSchemas([input.entryPoint()]) const units: CompilationUnit[] = [] + const documents = input.allJsonSchemaDocuments() - const refToFilename: Record = {} + const fileNameForSchema = (filename: string) => + `./${path.basename(filename, path.extname(filename))}.ts` - // Hacky way to get the ref for each top-level schema. - // In our synthetic OpenAPI, they were created as: - // name -> { $ref: 'full/path/to/file.json#' } + for (const document of documents) { + const filename = fileNameForSchema(document.filename) - const entryPoint = input.entryPoint() - if (entryPoint.components?.schemas) { - for (const [name, schema] of Object.entries( - entryPoint.components.schemas, - )) { - if (isRef(schema)) { - const ref = schema.$ref.split("#")[0] - if (ref) { - refToFilename[ref] = `./${name}.ts` - } - } - } - } - - for (const [name, schema] of Object.entries(allSchemas)) { - const filename = `${name}.ts` const imports = new ImportBuilder({ ...importBuilderConfig, unit: {filename}, }) - const typeBuilder = new JsonSchemaTypeBuilder( + const typeBuilder = await TypeBuilder.fromSchemaProvider( filename, input, config.compilerOptions, - {allowAny}, - refToFilename, - imports, + {allowAny, refToFilename: fileNameForSchema}, ) - // biome-ignore lint/suspicious/noExplicitAny: generics - let schemaBuilder: AbstractSchemaBuilder - if (config.schemaBuilder === "zod-v4") { - schemaBuilder = new JsonSchemaZodV4Builder( - filename, - input, - {allowAny}, - imports, - typeBuilder, - refToFilename, - imports, - ) - } else if (config.schemaBuilder === "zod-v3") { - schemaBuilder = new JsonSchemaZodV3Builder( - filename, - input, - {allowAny}, - imports, - typeBuilder, - refToFilename, - imports, - ) - } else if (config.schemaBuilder === "joi") { - schemaBuilder = new JsonSchemaJoiBuilder( - filename, - input, - {allowAny}, - imports, - typeBuilder, - refToFilename, - imports, - ) - } else { - throw new Error(`Unknown schema builder: ${config.schemaBuilder}`) - } + const schemaBuilder = await schemaBuilderFactory( + filename, + input, + config.schemaBuilder, + {allowAny, refToFilename: fileNameForSchema}, + imports, + typeBuilder, + ) const typeName = typeBuilder.getTypeNameFromRef({ - $ref: `#/components/schemas/${name}`, + $ref: `#/components/schemas/${path.basename(document.filename, path.extname(document.filename))}`, }) const schemaName = schemaBuilder.getSchemaNameFromRef({ - $ref: `#/components/schemas/${name}`, + $ref: `#/components/schemas/${path.basename(document.filename, path.extname(document.filename))}`, }) - const typeCode = typeBuilder.schemaObjectToType(schema) - const schemaCode = schemaBuilder.fromModel(schema, true) + const typeCode = typeBuilder + .withImports(imports) + .schemaObjectToType(document.schema) + const schemaCode = schemaBuilder + .withImports(imports) + .fromModel(document.schema, true) const typeExport = `export type ${typeName} = ${typeCode}` const schemaExport = `export const ${schemaName} = ${schemaCode}` diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.unit.spec.ts new file mode 100644 index 000000000..19d557f1e --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/common/typescript-json-schema.generator.unit.spec.ts @@ -0,0 +1,103 @@ +import {describe, expect, it, vi} from "vitest" +import {FakeSchemaProvider} from "../../test/fake-schema-provider.ts" +import {irFixture as ir} from "../../test/ir-model.fixtures.test-utils.ts" +import {TypescriptFormatterBiome} from "./typescript-formatter.biome.ts" +import {generateTypescriptJsonSchema} from "./typescript-json-schema.generator.ts" + +describe("generateTypescriptJsonSchema", () => { + it("should not import from itself", async () => { + const input = new FakeSchemaProvider() + const formatter = await TypescriptFormatterBiome.createNodeFormatter() + + // todo: use real emitter class with in-memory filesystem instead of mock. + const emitter = { + emitGenerationResult: vi.fn(), + } + + const addressFilename = "address.json" + const userFilename = "user.json" + + input.registerJsonSchemaDocument( + addressFilename, + ir.object({ + properties: { + street: ir.string(), + }, + }), + ) + + input.registerJsonSchemaDocument( + userFilename, + ir.object({ + properties: { + name: ir.string(), + address: {$ref: addressFilename}, + self: {$ref: userFilename}, + }, + }), + ) + + await generateTypescriptJsonSchema({ + input, + emitter, + schemaBuilder: "zod-v4", + compilerOptions: {}, + allowAny: false, + isEsmProject: true, + } as any) + + const units = emitter.emitGenerationResult.mock.calls[0]?.[0] ?? [] + const userUnit = units.find((u: any) => u.filename === "./user.ts") + const addresUnit = units.find((u: any) => u.filename === "./address.ts") + const userContent = await formatter + .format( + userUnit.filename, + userUnit.getRawFileContent({allowUnusedImports: true}), + ) + .then((it) => it.result) + const addressContent = await formatter + .format( + addresUnit.filename, + addresUnit.getRawFileContent({allowUnusedImports: true}), + ) + .then((it) => it.result) + + expect(userContent).toMatchInlineSnapshot(` + "/** AUTOGENERATED - DO NOT EDIT **/ + /* tslint:disable */ + /* eslint-disable */ + + import { z } from "zod/v4" + import { s_address, type t_address } from "./address.ts" + + export type t_user = { + address?: t_address + name?: string + self?: t_user + } + + export const s_user = z.object({ + name: z.string().optional(), + address: s_address.optional(), + get self() { + return s_user.optional() + }, + }) + " + `) + expect(addressContent).toMatchInlineSnapshot(` + "/** AUTOGENERATED - DO NOT EDIT **/ + /* tslint:disable */ + /* eslint-disable */ + + import { z } from "zod/v4" + + export type t_address = { + street?: string + } + + export const s_address = z.object({ street: z.string().optional() }) + " + `) + }) +})