From cfaa34ed3ec057d9fb467b6f6494b30c28679181 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 1/8] perf: build large generated modules in chunks ts-morph re-parses the whole source file on every insert, so emitting types.ts, commands.ts and valibot.ts one statement at a time was quadratic in their size. Their statements now go into scratch files of ten and join each module once it is complete, and each command class trims its default output argument while it is still small. A body alias declared inside the arguments of the alias that unions them could land in the next chunk, after it, so those are declared first. Output is byte-identical: openai generates in 8s rather than 59s, docker in 17s rather than 48s. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- lib/chunks.ts | 74 ++++++++++++ lib/process-document.ts | 105 +++++++++--------- lib/process-schema.ts | 5 +- lib/valibot.ts | 11 +- 8 files changed, 141 insertions(+), 62 deletions(-) create mode 100644 lib/chunks.ts diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 2a95ecc..4c18f22 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#generator": "84afca0df9d24a90f538c6bc00f653f5", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 039ee59..c45048d 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#generator": "84afca0df9d24a90f538c6bc00f653f5", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index b4f3c70..8c3324d 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#generator": "84afca0df9d24a90f538c6bc00f653f5", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index f397e5b..8505577 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#generator": "84afca0df9d24a90f538c6bc00f653f5", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/lib/chunks.ts b/lib/chunks.ts new file mode 100644 index 0000000..cfd97c5 --- /dev/null +++ b/lib/chunks.ts @@ -0,0 +1,74 @@ +import type { SourceFile } from "ts-morph"; + +// an insert re-parses its whole chunk, so a small chunk keeps inserts cheap +const chunkSize = 10; + +const chunksOf = new WeakMap(); + +/** + * ts-morph re-parses a whole file on every insert, so a module of thousands + * of statements is built in small scratch files that join it once complete. + * + * A statement declared while building the arguments of another can start a + * new chunk and land after it, so declare it first + */ +export function chunkOf(file: SourceFile) { + const chunks = chunksOf.get(file) ?? []; + const last = chunks.at(-1); + + if (last && last.getStatements().length < chunkSize) { + return last; + } + + const chunk = file + .getProject() + .createSourceFile(`${file.getFilePath()}.${chunks.length}.chunk.ts`, "", { + overwrite: true, + }); + + chunks.push(chunk); + chunksOf.set(file, chunks); + + return chunk; +} + +// the gap ts-morph left between statements, blank only between classes +function separatorIn(chunk: SourceFile) { + const [before, last] = chunk.getStatements().slice(-2); + + return before && last + ? chunk.getFullText().slice(before.getEnd(), last.getStart(true)) + : "\n"; +} + +/** + * Moves the chunked statements into the module, after its imports. Call it + * before anything reads the module back + */ +export function joinChunks(file: SourceFile) { + const chunks = chunksOf.get(file) ?? []; + + if (chunks.length > 0) { + const text = chunks + .map((chunk, index) => { + const next = chunks[index + 1]; + const body = chunk.getFullText().trim(); + + return next ? body + separatorIn(chunk) : body; + }) + .join(""); + + // a typed insert sets itself off from the imports, and text does not + file.addStatements( + file.getStatements().length > 0 + ? (writer) => writer.newLine().write(text) + : text, + ); + } + + for (const chunk of chunks) { + chunk.forget(); + } + + chunksOf.delete(file); +} diff --git a/lib/process-document.ts b/lib/process-document.ts index 109f6ac..5d9ec1b 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -20,6 +20,7 @@ import { Writers, } from "ts-morph"; import type { Simplify } from "type-fest"; +import { chunkOf, joinChunks } from "./chunks.ts"; import { addSchemaImportsToHonoFile, createHonoFile, @@ -618,7 +619,7 @@ function declareCommandClass( "Command", ); - const commandClass = commandsFile.addClass({ + const commandClass = chunkOf(commandsFile).addClass({ name: commandName, isExported: true, extends: "Command", @@ -903,7 +904,7 @@ function addQueryType( ) { const queryType = queryParameters.length > 0 - ? documentCtx.typesFile.addTypeAlias({ + ? chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "INVALID", "Query"), docs: deprecationDocs, isExported: true, @@ -934,7 +935,7 @@ function addHeaderType( ) { const headerType = headerParameters.length > 0 - ? documentCtx.typesFile.addTypeAlias({ + ? chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "INVALID", "Header"), docs: deprecationDocs, isExported: true, @@ -997,7 +998,7 @@ function jsonBodyTypeOf( schema, ); - return documentCtx.typesFile.addTypeAlias({ + return chunkOf(documentCtx.typesFile).addTypeAlias({ name, docs: deprecationDocs, type: typeof type.type === "function" ? type.type : String(type.type), @@ -1035,35 +1036,37 @@ function resolveBodyTypes( ); } - const nonJsonBodyType = - !jsonBodyType && nonJsonBodyEntries.length > 0 - ? documentCtx.typesFile.addTypeAlias({ - docs: deprecationDocs, - name: pascalCase( - `${commandClass.getName() || "INVALID"} Body NonJson`, - ), - isExported: true, - type: Writers.objectType({ - properties: [ - { - name: nonJsonBodyPropName, - type: createUnion( - ...nonJsonBodyEntries.map(([contentType, _mediaTypeObj]) => { - const nonJsonBody = documentCtx.typesFile.addTypeAlias({ - name: pascalCase( - `${commandClass.getName() || "INVALID"} Body ${contentType}`, - ), - type: "NonNullable", - }); - - return nonJsonBody.getName(); - }), - ), - }, - ], - }), - }) - : undefined; + const hasNonJsonBody = !jsonBodyType && nonJsonBodyEntries.length > 0; + + // declared before the alias that unions them, as chunkOf requires + const nonJsonBodyNames = hasNonJsonBody + ? nonJsonBodyEntries.map(([contentType]) => + chunkOf(documentCtx.typesFile) + .addTypeAlias({ + name: pascalCase( + `${commandClass.getName() || "INVALID"} Body ${contentType}`, + ), + type: "NonNullable", + }) + .getName(), + ) + : []; + + const nonJsonBodyType = hasNonJsonBody + ? chunkOf(documentCtx.typesFile).addTypeAlias({ + docs: deprecationDocs, + name: pascalCase(`${commandClass.getName() || "INVALID"} Body NonJson`), + isExported: true, + type: Writers.objectType({ + properties: [ + { + name: nonJsonBodyPropName, + type: createUnion(...nonJsonBodyNames), + }, + ], + }), + }) + : undefined; return { jsonRequestBodyObject, jsonBodyType, nonJsonBodyType }; } @@ -1074,7 +1077,7 @@ function addParamsType( pathParameters: oas30.ParameterObject[], ) { return pathParameters.length > 0 - ? documentCtx.typesFile.addTypeAlias({ + ? chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(`${commandClass.getName() || "INVALID"}Params`), docs: deprecationDocs, type: Writers.objectType({ @@ -1126,7 +1129,7 @@ function addInputType( ) { const bodyType = (jsonBodyType && - documentCtx.typesFile.addTypeAlias({ + chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "", "Body"), type: jsonBodyType.getName(), isExported: true, @@ -1149,7 +1152,7 @@ function addInputType( const wrappedJsonBodyType = wrapJsonBody && jsonBodyType - ? documentCtx.typesFile.addTypeAlias({ + ? chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "", "BodyWrapper"), type: Writers.objectType({ properties: [{ name: inputBodyName, type: jsonBodyType.getName() }], @@ -1165,7 +1168,7 @@ function addInputType( queryType?.getName(), ); - const inputType = documentCtx.typesFile.addTypeAlias({ + const inputType = chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "", "Input"), type: inputTypeNode, isExported: true, @@ -1355,7 +1358,7 @@ function addReferencedOutput( // The value is JSON.stringified, which drops `undefined`, so // optional fields may hold `undefined`. Mirrors the `input*` // prefix used for the lax variant in the valibot module - documentCtx.typesFile.addTypeAlias({ + chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase("Input", commandClass.getName() || "INVALID", "Response"), type: `UndefinedOnPartialDeep<${outputTypeName}>`, isExported: true, @@ -1374,7 +1377,7 @@ function addInlineOutput( schema, ); - const responseTypeAlias = documentCtx.typesFile.addTypeAlias({ + const responseTypeAlias = chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "INVALID", "Output"), type: typeof outputType.type === "function" @@ -1388,7 +1391,7 @@ function addInlineOutput( commandClass.getExtends()?.addTypeArgument(responseTypeAlias.getName()); documentCtx.outputTypes.add(responseTypeAlias); - documentCtx.typesFile.addTypeAlias({ + chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase("Input", commandClass.getName() || "INVALID", "Response"), type: `UndefinedOnPartialDeep<${responseTypeAlias.getName()}>`, isExported: true, @@ -1529,7 +1532,7 @@ function addSequentialOutput( schema, ); - const outputTypeAlias = documentCtx.typesFile.addTypeAlias({ + const outputTypeAlias = chunkOf(documentCtx.typesFile).addTypeAlias({ name: pascalCase(commandClass.getName() || "INVALID", "Output"), type: outputType.type ?? unspecifiedKeyword, isExported: true, @@ -1980,6 +1983,7 @@ function processOperation( registerValidatedCommand(documentCtx, operationCtx.commandName, wireSchemas); addQueryAndHeaderTypeArguments(operationCtx); addCommandConstructor(operationCtx, path); + trimDefaultOutputArgument(command.commandClass); } function emitOperations( @@ -2240,18 +2244,16 @@ function emitHonoModule( return honoFile; } -function trimDefaultOutputArguments(commandsFile: SourceFile) { +function trimDefaultOutputArgument(commandClass: ClassDeclaration) { // `Command` defaults its output to `unknown`, so an explicit `unknown` // repeats the default. A trailing argument can go, while an earlier one // holds the position of the arguments after it - for (const commandClass of commandsFile.getClasses()) { - const base = commandClass.getExtends(); - const typeArguments = base?.getTypeArguments() ?? []; - const last = typeArguments.at(-1); + const base = commandClass.getExtends(); + const typeArguments = base?.getTypeArguments() ?? []; + const last = typeArguments.at(-1); - if (base && typeArguments.length > 1 && last?.getText() === "unknown") { - base.removeTypeArgument(last); - } + if (base && typeArguments.length > 1 && last?.getText() === "unknown") { + base.removeTypeArgument(last); } } @@ -2287,6 +2289,9 @@ export async function processOpenApiDocument( emitOperations(documentCtx, schema, tags); emitClientModule(documentCtx, schema); emitValidatedModule(documentCtx); + joinChunks(files.typesFile); + joinChunks(files.commandsFile); + joinChunks(files.valibotFile); files.mainFile.organizeImports(); @@ -2303,7 +2308,5 @@ export async function processOpenApiDocument( documentCtx.allOperations, ); - trimDefaultOutputArguments(files.commandsFile); - return { ...files, honoFile }; } diff --git a/lib/process-schema.ts b/lib/process-schema.ts index da73ff6..0cc22f7 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -11,6 +11,7 @@ import { type WriterFunction, Writers, } from "ts-morph"; +import { chunkOf } from "./chunks.ts"; import { type SchemaNode, type SchemaObject, @@ -715,7 +716,7 @@ function registerAlias( type: string | WriterFunction, description?: string, ) { - const typeAlias = typesFile.addTypeAlias({ + const typeAlias = chunkOf(typesFile).addTypeAlias({ name: pascalCase(schemaName), isExported: true, type, @@ -888,7 +889,7 @@ export function registerTypesFromSchema( ] : []; - const stringUnion = typesFile.addTypeAlias({ + const stringUnion = chunkOf(typesFile).addTypeAlias({ name: pascalCase(schemaName), isExported: true, type: maybeUnion(...schemaObject.enum.map((e) => JSON.stringify(e))), diff --git a/lib/valibot.ts b/lib/valibot.ts index 07d7413..f323f15 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -12,6 +12,7 @@ import { } from "ts-morph"; import type { Primitive } from "type-fest"; import type * as v from "valibot"; +import { chunkOf } from "./chunks.ts"; import { type SchemaNode, type SchemaObject, @@ -903,7 +904,7 @@ export function registerValidatorFromSchema( : []; // Input schema — always emitted (TS-side, allows undefined, no wire coercion) - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, docs, @@ -921,7 +922,7 @@ export function registerValidatorFromSchema( // exactOptional, which are equivalent on JSON-parsed data if (!inputOnly) { if (shouldCoerceSchema(schemaObject)) { - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ @@ -932,7 +933,7 @@ export function registerValidatorFromSchema( ], }); } else { - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ @@ -1081,7 +1082,7 @@ function emitNamePair( const inputName = camelcase(["input", commandName, segment, "schema"]); const wireName = camelcase([commandName, segment, "schema"]); - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ @@ -1093,7 +1094,7 @@ function emitNamePair( }); if (!inputOnly) { - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ From ff6a4f6f55ab1956c7230587101bdff5dad88875 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 2/8] fix: take component dependencies from subschemas only getDependents collected every $ref anywhere under a schema, including inside example, default, const and enum values. A $ref-shaped key in sample data became a dependency, and could close a cycle that failed the component sort. It now walks only the keywords that hold subschemas, so a property named default or example still counts. Co-Authored-By: LLM --- __tests__/__snapshots__/refs.test.ts.snap | 68 +++++++++++++++++++ .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- __tests__/refs.test.ts | 46 +++++++++++++ lib/utils.ts | 60 ++++++++++++++-- 7 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 __tests__/__snapshots__/refs.test.ts.snap create mode 100644 __tests__/refs.test.ts diff --git a/__tests__/__snapshots__/refs.test.ts.snap b/__tests__/__snapshots__/refs.test.ts.snap new file mode 100644 index 0000000..1061a22 --- /dev/null +++ b/__tests__/__snapshots__/refs.test.ts.snap @@ -0,0 +1,68 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`a $ref inside example data is not a dependency > types.ts 1`] = ` +"export type Child = { + "name"?: string; + }; +export type Parent = { + "child"?: Child; + }; +" +`; + +exports[`a $ref inside example data is not a dependency > valibot.ts 1`] = ` +"import * as v from "valibot"; + +export const inputChildSchema = v.looseObject( + { + "name": v.optional(v.string()) + , + }); +export const childSchema = v.looseObject( + { + "name": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputParentSchema = v.looseObject( + { + "child": v.optional(inputChildSchema) + , + }); +export const parentSchema = v.looseObject( + { + "child": v.exactOptional(childSchema) + , + }); +" +`; + +exports[`a property named after a keyword still orders by its $ref > types.ts 1`] = ` +"export type Preset = "low" | "high"; +export type Settings = { + "default"?: Preset; + "example"?: Preset; + }; +" +`; + +exports[`a property named after a keyword still orders by its $ref > valibot.ts 1`] = ` +"import * as v from "valibot"; + +export const inputPresetSchema = v.picklist(["low", "high"]); +export const presetSchema = inputPresetSchema; +export const inputSettingsSchema = v.looseObject( + { + "default": v.optional(inputPresetSchema) + , + "example": v.optional(inputPresetSchema) + , + }); +export const settingsSchema = v.looseObject( + { + "default": v.exactOptional(presetSchema) + , + "example": v.exactOptional(presetSchema) + , + }); +" +`; diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 4c18f22..446459c 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "84afca0df9d24a90f538c6bc00f653f5", + "#generator": "65730226ce0edda465d4e30fbc37319d", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index c45048d..9b5e33a 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "84afca0df9d24a90f538c6bc00f653f5", + "#generator": "65730226ce0edda465d4e30fbc37319d", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 8c3324d..b1563a7 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "84afca0df9d24a90f538c6bc00f653f5", + "#generator": "65730226ce0edda465d4e30fbc37319d", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 8505577..073539b 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "84afca0df9d24a90f538c6bc00f653f5", + "#generator": "65730226ce0edda465d4e30fbc37319d", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts new file mode 100644 index 0000000..dd2f59f --- /dev/null +++ b/__tests__/refs.test.ts @@ -0,0 +1,46 @@ +import type { oas32 } from "openapi3-ts"; +import { expect, test } from "vitest"; +import { processOpenApiDocument } from "../lib/process-document.ts"; + +function generateSchemas(schemas: Record) { + return processOpenApiDocument("/tmp/refs", { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + components: { schemas }, + }); +} + +test("a $ref inside example data is not a dependency", async () => { + const result = await generateSchemas({ + Parent: { + type: "object", + properties: { child: { $ref: "#/components/schemas/Child" } }, + }, + Child: { + type: "object", + properties: { name: { type: "string" } }, + example: { $ref: "#/components/schemas/Parent" }, + default: { $ref: "#/components/schemas/Parent" }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); + +test("a property named after a keyword still orders by its $ref", async () => { + const result = await generateSchemas({ + Settings: { + type: "object", + properties: { + default: { $ref: "#/components/schemas/Preset" }, + example: { $ref: "#/components/schemas/Preset" }, + }, + }, + Preset: { type: "string", enum: ["low", "high"] }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); diff --git a/lib/utils.ts b/lib/utils.ts index 9a0208c..2e18534 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -21,20 +21,66 @@ export function isNotNullOrUndefined(obj: T | null | undefined): obj is T { return obj !== null && obj !== undefined; } +// each keyword holds a subschema, or an array of them +const subschemaKeywords = new Set([ + "items", + "prefixItems", + "additionalItems", + "additionalProperties", + "unevaluatedItems", + "unevaluatedProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "allOf", + "anyOf", + "oneOf", +]); + +// each keyword maps names to subschemas +const subschemaMapKeywords = new Set([ + "properties", + "patternProperties", + "dependentSchemas", + "$defs", + "definitions", +]); + /** - * Every $ref anywhere under a schema. Items, additionalProperties and - * combinators nest them at any depth, and a schema registers after all of them + * Every $ref a schema depends on, at any depth. Only subschemas count. An + * example, default, const or enum is data, and a `$ref` key inside it is not + * a reference */ -export function getDependents(obj: unknown): string[] { - if (isReferenceObject(obj)) { - return [obj.$ref]; +export function getDependents(schema: unknown): string[] { + if (isReferenceObject(schema)) { + return [schema.$ref]; } - if (typeof obj !== "object" || obj === null) { + if (typeof schema !== "object" || schema === null) { return []; } - return Object.values(obj).flatMap((value) => getDependents(value)); + const entries = Object.entries(schema); + + return [ + ...entries + .filter(([keyword]) => subschemaKeywords.has(keyword)) + .flatMap(([, value]) => + Array.isArray(value) + ? value.flatMap((item) => getDependents(item)) + : getDependents(value), + ), + ...entries + .filter(([keyword]) => subschemaMapKeywords.has(keyword)) + .flatMap(([, value]) => + typeof value === "object" && value !== null + ? Object.values(value).flatMap((item) => getDependents(item)) + : [], + ), + ]; } export function camelCase(...str: string[]): string { From aca1fcd6e0475a7c67d05ca5e1fed085a407516d Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 3/8] fix: name the schema and ref when a component refers to nothing A component $ref to a schema the document does not define failed later as "ref used before available", the message of an ordering bug. The component sort now rejects it first, naming the schema and the ref. Co-Authored-By: LLM --- .../fixtures/docker/.openapi-codegen-manifest.json | 2 +- .../fixtures/openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../fixtures/test1/.openapi-codegen-manifest.json | 2 +- __tests__/refs.test.ts | 13 +++++++++++++ lib/process-document.ts | 11 +++++++++++ 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 446459c..d36dec2 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "65730226ce0edda465d4e30fbc37319d", + "#generator": "68f636a6745a59994445d693b3938074", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 9b5e33a..cc3349d 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "65730226ce0edda465d4e30fbc37319d", + "#generator": "68f636a6745a59994445d693b3938074", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index b1563a7..6251d78 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "65730226ce0edda465d4e30fbc37319d", + "#generator": "68f636a6745a59994445d693b3938074", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 073539b..6736374 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "65730226ce0edda465d4e30fbc37319d", + "#generator": "68f636a6745a59994445d693b3938074", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts index dd2f59f..368dbe4 100644 --- a/__tests__/refs.test.ts +++ b/__tests__/refs.test.ts @@ -44,3 +44,16 @@ test("a property named after a keyword still orders by its $ref", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); }); + +test("a $ref to an undefined schema names the schema and the ref", async () => { + await expect( + generateSchemas({ + Order: { + type: "object", + properties: { customer: { $ref: "#/components/schemas/Customer" } }, + }, + }), + ).rejects.toThrow( + "Order refers to #/components/schemas/Customer, which is not a schema in components.schemas", + ); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index 5d9ec1b..a67d327 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -500,9 +500,20 @@ function ensureTypeImport( function sortedComponentSchemas(schema: oas32.OpenAPIObject) { const schemas = Object.entries(schema.components?.schemas || {}); + const defined = new Set( + schemas.map(([schemaName]) => `#/components/schemas/${schemaName}`), + ); const schemaGraph = schemas.flatMap(([schemaName, schemaObject]) => { const deps = getDependents(schemaObject); + const missing = deps.find((dep) => !defined.has(dep)); + + if (missing) { + throw new Error( + `${schemaName} refers to ${missing}, which is not a schema in components.schemas`, + ); + } + // oxlint-disable-next-line block65/no-explicit-return-type -- inference widens the pair to string[], and toposort takes a mutable tuple return deps.map((dep): [string, string] => [ `#/components/schemas/${schemaName}`, From 09b541db0204b337829861b79b7273c30860e138 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 4/8] fix: resolve escaped, encoded and nested component $refs Schemas registered under `#/components/schemas/` as written, so a $ref that escaped the name as a JSON pointer does (`~1` for `/`, `~0` for `~`), percent-encoded it, or pointed into part of a schema found nothing and failed as "ref used before available". The document's local $refs are now normalized before generation. A ref is percent-decoded, a component registers under its escaped name, and a ref into part of a component is replaced by that subschema, keeping any keywords beside the $ref. A ref into its own schema or to a missing part is refused by name. Such names also cased into invalid identifiers (`Pet~tag`, `inputPet/kindSchema`), so casing now splits on every character an identifier cannot hold, and valibot names use the same helper. Co-Authored-By: LLM --- __tests__/__snapshots__/refs.test.ts.snap | 102 ++++++++++++++ .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- __tests__/refs.test.ts | 85 ++++++++++++ lib/process-document.ts | 17 +-- lib/process-schema.ts | 5 +- lib/refs.ts | 130 ++++++++++++++++++ lib/utils.ts | 7 +- lib/valibot.ts | 13 +- 11 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 lib/refs.ts diff --git a/__tests__/__snapshots__/refs.test.ts.snap b/__tests__/__snapshots__/refs.test.ts.snap index 1061a22..568a1b8 100644 --- a/__tests__/__snapshots__/refs.test.ts.snap +++ b/__tests__/__snapshots__/refs.test.ts.snap @@ -36,6 +36,74 @@ export const parentSchema = v.looseObject( " `; +exports[`a $ref into part of a schema inlines that subschema > types.ts 1`] = ` +"export type Address = { + "postcode"?: string; + }; +export type Parcel = { + "to"?: string; + }; +" +`; + +exports[`a $ref into part of a schema inlines that subschema > valibot.ts 1`] = ` +"import * as v from "valibot"; + +export const inputAddressSchema = v.looseObject( + { + "postcode": v.optional(v.pipe(v.string(), v.regex(new RegExp("^[0-9]{4}$")))) + , + }); +export const addressSchema = v.looseObject( + { + "postcode": v.exactOptional(v.pipe(v.string(), v.regex(new RegExp("^[0-9]{4}$")))) + , + }); +export const inputParcelSchema = v.looseObject( + { + /** + * Where the parcel goes + */ + "to": v.optional(v.pipe(v.string(), v.regex(new RegExp("^[0-9]{4}$")))) + , + }); +export const parcelSchema = v.looseObject( + { + /** + * Where the parcel goes + */ + "to": v.exactOptional(v.pipe(v.string(), v.regex(new RegExp("^[0-9]{4}$")))) + , + }); +" +`; + +exports[`a percent-encoded $ref finds its schema > types.ts 1`] = ` +"export type PetKind = "cat" | "dog"; +export type Pet = { + "kind"?: PetKind; + }; +" +`; + +exports[`a percent-encoded $ref finds its schema > valibot.ts 1`] = ` +"import * as v from "valibot"; + +export const inputPetKindSchema = v.picklist(["cat", "dog"]); +export const petKindSchema = inputPetKindSchema; +export const inputPetSchema = v.looseObject( + { + "kind": v.optional(inputPetKindSchema) + , + }); +export const petSchema = v.looseObject( + { + "kind": v.exactOptional(petKindSchema) + , + }); +" +`; + exports[`a property named after a keyword still orders by its $ref > types.ts 1`] = ` "export type Preset = "low" | "high"; export type Settings = { @@ -66,3 +134,37 @@ export const settingsSchema = v.looseObject( }); " `; + +exports[`a schema name with a slash or tilde is found through its escaped $ref > types.ts 1`] = ` +"export type PetTag = string; +export type PetKind = "cat" | "dog"; +export type Pet = { + "kind"?: PetKind; + "tag"?: PetTag; + }; +" +`; + +exports[`a schema name with a slash or tilde is found through its escaped $ref > valibot.ts 1`] = ` +"import * as v from "valibot"; + +export const inputPetTagSchema = v.string(); +export const petTagSchema = v.pipe(v.string(), v.trim()); +export const inputPetKindSchema = v.picklist(["cat", "dog"]); +export const petKindSchema = inputPetKindSchema; +export const inputPetSchema = v.looseObject( + { + "kind": v.optional(inputPetKindSchema) + , + "tag": v.optional(inputPetTagSchema) + , + }); +export const petSchema = v.looseObject( + { + "kind": v.exactOptional(petKindSchema) + , + "tag": v.exactOptional(petTagSchema) + , + }); +" +`; diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index d36dec2..80ce38f 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "68f636a6745a59994445d693b3938074", + "#generator": "0be4b85c81ade4ffa55403d163b5dafe", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index cc3349d..6665bc1 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "68f636a6745a59994445d693b3938074", + "#generator": "0be4b85c81ade4ffa55403d163b5dafe", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 6251d78..3551e9a 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "68f636a6745a59994445d693b3938074", + "#generator": "0be4b85c81ade4ffa55403d163b5dafe", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 6736374..9f223b3 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "68f636a6745a59994445d693b3938074", + "#generator": "0be4b85c81ade4ffa55403d163b5dafe", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts index 368dbe4..8a47c91 100644 --- a/__tests__/refs.test.ts +++ b/__tests__/refs.test.ts @@ -57,3 +57,88 @@ test("a $ref to an undefined schema names the schema and the ref", async () => { "Order refers to #/components/schemas/Customer, which is not a schema in components.schemas", ); }); + +test("a schema name with a slash or tilde is found through its escaped $ref", async () => { + const result = await generateSchemas({ + "pet/kind": { type: "string", enum: ["cat", "dog"] }, + "pet~tag": { type: "string" }, + Pet: { + type: "object", + properties: { + kind: { $ref: "#/components/schemas/pet~1kind" }, + tag: { $ref: "#/components/schemas/pet~0tag" }, + }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); + +test("a percent-encoded $ref finds its schema", async () => { + const result = await generateSchemas({ + "Pet Kind": { type: "string", enum: ["cat", "dog"] }, + Pet: { + type: "object", + properties: { kind: { $ref: "#/components/schemas/Pet%20Kind" } }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); + +test("a $ref into part of a schema inlines that subschema", async () => { + const result = await generateSchemas({ + Address: { + type: "object", + $defs: { Postcode: { type: "string", pattern: "^[0-9]{4}$" } }, + properties: { + postcode: { $ref: "#/components/schemas/Address/$defs/Postcode" }, + }, + }, + Parcel: { + type: "object", + properties: { + to: { + $ref: "#/components/schemas/Address/properties/postcode", + description: "Where the parcel goes", + }, + }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); + +test("a $ref into its own schema is refused", async () => { + await expect( + generateSchemas({ + Folder: { + type: "object", + properties: { + parent: { $ref: "#/components/schemas/Folder/properties/parent" }, + }, + }, + }), + ).rejects.toThrow( + "#/components/schemas/Folder/properties/parent refers into itself, so it cannot be inlined", + ); +}); + +test("a $ref into a part a schema lacks is refused", async () => { + await expect( + generateSchemas({ + Address: { type: "object", properties: { street: { type: "string" } } }, + Parcel: { + type: "object", + properties: { + to: { $ref: "#/components/schemas/Address/properties/postcode" }, + }, + }, + }), + ).rejects.toThrow( + "#/components/schemas/Address/properties/postcode does not point at a schema in the document", + ); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index a67d327..642caa3 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -29,6 +29,7 @@ import { queryStyles, } from "./hono.ts"; import { registerTypesFromSchema, schemaToType } from "./process-schema.ts"; +import { normalizeRefs, schemaRef } from "./refs.ts"; import { type ReferenceObject, type SchemaNode, @@ -500,9 +501,7 @@ function ensureTypeImport( function sortedComponentSchemas(schema: oas32.OpenAPIObject) { const schemas = Object.entries(schema.components?.schemas || {}); - const defined = new Set( - schemas.map(([schemaName]) => `#/components/schemas/${schemaName}`), - ); + const defined = new Set(schemas.map(([schemaName]) => schemaRef(schemaName))); const schemaGraph = schemas.flatMap(([schemaName, schemaObject]) => { const deps = getDependents(schemaObject); @@ -515,18 +514,13 @@ function sortedComponentSchemas(schema: oas32.OpenAPIObject) { } // oxlint-disable-next-line block65/no-explicit-return-type -- inference widens the pair to string[], and toposort takes a mutable tuple - return deps.map((dep): [string, string] => [ - `#/components/schemas/${schemaName}`, - dep, - ]); + return deps.map((dep): [string, string] => [schemaRef(schemaName), dep]); }); const sorted = toposort(schemaGraph).toReversed(); return schemas.toSorted( - ([a], [b]) => - sorted.indexOf(`#/components/schemas/${a}`) - - sorted.indexOf(`#/components/schemas/${b}`), + ([a], [b]) => sorted.indexOf(schemaRef(a)) - sorted.indexOf(schemaRef(b)), ); } @@ -2270,10 +2264,11 @@ function trimDefaultOutputArgument(commandClass: ClassDeclaration) { export async function processOpenApiDocument( outputDir: string, - schema: Simplify, + document: Simplify, tags?: string[], options?: CodegenOptions, ) { + const schema = normalizeRefs(document); const project = new Project(); const files = createOutputFiles(project, outputDir); const refs = await $RefParser.resolve(schema); diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 0cc22f7..97a01b7 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -12,6 +12,7 @@ import { Writers, } from "ts-morph"; import { chunkOf } from "./chunks.ts"; +import { schemaRef } from "./refs.ts"; import { type SchemaNode, type SchemaObject, @@ -728,7 +729,7 @@ function registerAlias( }); } - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + typesAndInterfaces.set(schemaRef(schemaName), typeAlias); } function combinatorAliasType( @@ -896,7 +897,7 @@ export function registerTypesFromSchema( docs, }); - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, stringUnion); + typesAndInterfaces.set(schemaRef(schemaName), stringUnion); } // deal with non-enum strings diff --git a/lib/refs.ts b/lib/refs.ts new file mode 100644 index 0000000..aefc46d --- /dev/null +++ b/lib/refs.ts @@ -0,0 +1,130 @@ +const componentSchemas = "#/components/schemas/"; + +/** + * Keys a component schema by the $ref that names it. A name holding `~` or + * `/` is escaped the way a JSON pointer writes it + */ +export function schemaRef(schemaName: string) { + return `${componentSchemas}${schemaName.replaceAll("~", "~0").replaceAll("/", "~1")}`; +} + +// a `$ref` key inside these is data, unless the key names a property +const dataKeywords = new Set([ + "example", + "examples", + "default", + "const", + "enum", +]); + +const nameMapKeywords = new Set([ + "schemas", + "properties", + "patternProperties", + "dependentSchemas", + "$defs", + "definitions", +]); + +function decodeFragment(ref: string) { + try { + return decodeURIComponent(ref); + } catch { + return ref; + } +} + +function pointerTokens(ref: string) { + return ref + .slice(2) + .split("/") + .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); +} + +function lookup(document: unknown, ref: string) { + let node = document; + + for (const token of pointerTokens(ref)) { + if ( + typeof node !== "object" || + node === null || + !Object.hasOwn(node, token) + ) { + throw new Error(`${ref} does not point at a schema in the document`); + } + + node = Reflect.get(node, token); + } + + return node; +} + +/** + * Leaves only $refs the generator can look up. A percent-encoded ref is + * decoded. A ref into part of a component schema is replaced by that + * subschema, so each schema ref left names a whole component + */ +export function normalizeRefs(document: T): T { + const visit = ( + node: unknown, + inNameMap: boolean, + inlining: string[], + ): unknown => { + if (Array.isArray(node)) { + return node.map((item) => visit(item, false, inlining)); + } + + if (typeof node !== "object" || node === null) { + return node; + } + + if (!inNameMap && "$ref" in node && typeof node.$ref === "string") { + const ref = node.$ref.startsWith("#") + ? decodeFragment(node.$ref) + : node.$ref; + const intoComponent = + ref.startsWith(componentSchemas) && + ref.slice(componentSchemas.length).includes("/"); + + if (!intoComponent) { + return { ...node, $ref: ref }; + } + + if (inlining.includes(ref)) { + throw new Error(`${ref} refers into itself, so it cannot be inlined`); + } + + const target = lookup(document, ref); + + const inlined = visit(target, false, [...inlining, ref]); + const { $ref: _, ...siblings } = node; + + // OAS 3.1 allows keywords such as description beside a $ref + return typeof inlined === "object" && + inlined !== null && + Object.keys(siblings).length > 0 + ? { + ...inlined, + ...Object.fromEntries( + Object.entries(siblings).map(([key, value]) => [ + key, + visit(value, false, inlining), + ]), + ), + } + : inlined; + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + !inNameMap && dataKeywords.has(key) + ? value + : visit(value, !inNameMap && nameMapKeywords.has(key), inlining), + ]), + ); + }; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- visit rebuilds the same shape and changes only $ref values + return visit(document, false, []) as T; +} diff --git a/lib/utils.ts b/lib/utils.ts index 2e18534..c956031 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -83,13 +83,16 @@ export function getDependents(schema: unknown): string[] { ]; } +// a schema name can hold `/`, `~` or other characters an identifier cannot +const nonIdentifier = /[^\p{L}\p{N}_$]+/u; + export function camelCase(...str: string[]): string { - return camelcase(str.flatMap((s) => s.split("/"))); + return camelcase(str.flatMap((s) => s.split(nonIdentifier))); } export function pascalCase(...str: string[]): string { return camelcase( - str.flatMap((s) => s.split("/")), + str.flatMap((s) => s.split(nonIdentifier)), { pascalCase: true }, ); } diff --git a/lib/valibot.ts b/lib/valibot.ts index f323f15..224018a 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import camelcase from "camelcase"; import type { oas30, oas32 } from "openapi3-ts"; import { type CodeBlockWriter, @@ -13,9 +12,11 @@ import { import type { Primitive } from "type-fest"; import type * as v from "valibot"; import { chunkOf } from "./chunks.ts"; +import { schemaRef } from "./refs.ts"; import { type SchemaNode, type SchemaObject, + camelCase, isSchemaObject, typedEntries, wordWrap, @@ -861,10 +862,10 @@ export function registerValidatorFromSchema( schemaObject: SchemaNode, inputOnly?: boolean, ) { - const inputName = camelcase(["input", schemaName, "schema"]); - const wireName = camelcase([schemaName, "schema"]); + const inputName = camelCase("input", schemaName, "schema"); + const wireName = camelCase(schemaName, "schema"); - validators.set(`#/components/schemas/${schemaName}`, { + validators.set(schemaRef(schemaName), { input: inputName, wire: wireName, }); @@ -1079,8 +1080,8 @@ function emitNamePair( initializer: (mode: SchemaMode) => WriterFunction | string, ): SchemaNamePair { const { valibotFile, commandName, inputOnly } = target; - const inputName = camelcase(["input", commandName, segment, "schema"]); - const wireName = camelcase([commandName, segment, "schema"]); + const inputName = camelCase("input", commandName, segment, "schema"); + const wireName = camelCase(commandName, segment, "schema"); chunkOf(valibotFile).addVariableStatement({ isExported: true, From 6c6749d1df538538bb957813344ece18dfceaa76 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 5/8] fix: reject a $ref to an undefined schema where it is made Only refs between component schemas were checked, so an operation's body, response or parameter that referred to a missing schema still failed as "ref used before available". Every component schema $ref in the document is now checked as the refs are normalized, and the error gives the JSON pointer of the ref. Co-Authored-By: LLM --- __tests__/refs.test.ts | 35 +++++++++++++++++++++++++++-- lib/refs.ts | 51 +++++++++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts index 8a47c91..dc5b407 100644 --- a/__tests__/refs.test.ts +++ b/__tests__/refs.test.ts @@ -45,7 +45,7 @@ test("a property named after a keyword still orders by its $ref", async () => { expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); }); -test("a $ref to an undefined schema names the schema and the ref", async () => { +test("a $ref to an undefined schema names where it is made", async () => { await expect( generateSchemas({ Order: { @@ -54,7 +54,38 @@ test("a $ref to an undefined schema names the schema and the ref", async () => { }, }), ).rejects.toThrow( - "Order refers to #/components/schemas/Customer, which is not a schema in components.schemas", + "#/components/schemas/Order/properties/customer refers to #/components/schemas/Customer, which is not a schema in components.schemas", + ); +}); + +test("an operation's $ref to an undefined schema names where it is made", async () => { + await expect( + processOpenApiDocument("/tmp/refs", { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/orders": { + get: { + operationId: "listOrdersCommand", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Order" }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ).rejects.toThrow( + "#/paths/~1orders/get/responses/200/content/application~1json/schema/items refers to #/components/schemas/Order, which is not a schema in components.schemas", ); }); diff --git a/lib/refs.ts b/lib/refs.ts index aefc46d..5bb2eba 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -1,3 +1,5 @@ +import type { oas32 } from "openapi3-ts"; + const componentSchemas = "#/components/schemas/"; /** @@ -5,7 +7,11 @@ const componentSchemas = "#/components/schemas/"; * `/` is escaped the way a JSON pointer writes it */ export function schemaRef(schemaName: string) { - return `${componentSchemas}${schemaName.replaceAll("~", "~0").replaceAll("/", "~1")}`; + return `${componentSchemas}${escapeToken(schemaName)}`; +} + +function escapeToken(token: string) { + return token.replaceAll("~", "~0").replaceAll("/", "~1"); } // a `$ref` key inside these is data, unless the key names a property @@ -34,11 +40,15 @@ function decodeFragment(ref: string) { } } +function unescapeToken(token: string) { + return token.replaceAll("~1", "/").replaceAll("~0", "~"); +} + function pointerTokens(ref: string) { return ref .slice(2) .split("/") - .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~")); + .map((token) => unescapeToken(token)); } function lookup(document: unknown, ref: string) { @@ -64,14 +74,19 @@ function lookup(document: unknown, ref: string) { * decoded. A ref into part of a component schema is replaced by that * subschema, so each schema ref left names a whole component */ -export function normalizeRefs(document: T): T { +export function normalizeRefs(document: oas32.OpenAPIObject) { + const schemas = document.components?.schemas ?? {}; + const visit = ( node: unknown, inNameMap: boolean, inlining: string[], + at: string, ): unknown => { if (Array.isArray(node)) { - return node.map((item) => visit(item, false, inlining)); + return node.map((item, index) => + visit(item, false, inlining, `${at}/${index}`), + ); } if (typeof node !== "object" || node === null) { @@ -86,6 +101,21 @@ export function normalizeRefs(document: T): T { ref.startsWith(componentSchemas) && ref.slice(componentSchemas.length).includes("/"); + const defined = + !ref.startsWith(componentSchemas) || + intoComponent || + Object.hasOwn( + schemas, + unescapeToken(ref.slice(componentSchemas.length)), + ); + + // the generator looks the name up much later, and far from here + if (!defined) { + throw new Error( + `${at} refers to ${ref}, which is not a schema in components.schemas`, + ); + } + if (!intoComponent) { return { ...node, $ref: ref }; } @@ -96,7 +126,7 @@ export function normalizeRefs(document: T): T { const target = lookup(document, ref); - const inlined = visit(target, false, [...inlining, ref]); + const inlined = visit(target, false, [...inlining, ref], ref); const { $ref: _, ...siblings } = node; // OAS 3.1 allows keywords such as description beside a $ref @@ -108,7 +138,7 @@ export function normalizeRefs(document: T): T { ...Object.fromEntries( Object.entries(siblings).map(([key, value]) => [ key, - visit(value, false, inlining), + visit(value, false, inlining, `${at}/${escapeToken(key)}`), ]), ), } @@ -120,11 +150,16 @@ export function normalizeRefs(document: T): T { key, !inNameMap && dataKeywords.has(key) ? value - : visit(value, !inNameMap && nameMapKeywords.has(key), inlining), + : visit( + value, + !inNameMap && nameMapKeywords.has(key), + inlining, + `${at}/${escapeToken(key)}`, + ), ]), ); }; // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- visit rebuilds the same shape and changes only $ref values - return visit(document, false, []) as T; + return visit(document, false, [], "#") as oas32.OpenAPIObject; } From 518c4bcd16e461b87fa6a7eecbc55aaad4ed267d Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 6/8] feat: generate recursive schemas A schema that contains itself, or schemas that refer to each other, failed the component sort with "Cyclic dependency". No order can satisfy a cycle, so the sort now drops the ref that closes each loop, and generation no longer needs the order for such refs. A type alias may name one declared later, so a type ref to a component not registered yet uses the name that component will register under. Validators are named for every component before any is emitted, and a ref to one not emitted yet reads it with v.lazy. The lazy getter is annotated with the generated type, imported from the types module, which stops TypeScript inferring the schema from itself. Only the output type is given, since a wire schema coerces its input. Acyclic documents generate byte-identical output. Co-Authored-By: LLM --- __tests__/__snapshots__/refs.test.ts.snap | 73 +++++++++++++++ __tests__/refs.test.ts | 40 ++++++++ lib/process-document.ts | 51 +++++++++- lib/process-schema.ts | 33 +++---- lib/refs.ts | 8 ++ lib/valibot.ts | 108 +++++++++++++++++++--- 6 files changed, 275 insertions(+), 38 deletions(-) diff --git a/__tests__/__snapshots__/refs.test.ts.snap b/__tests__/__snapshots__/refs.test.ts.snap index 568a1b8..6c6830e 100644 --- a/__tests__/__snapshots__/refs.test.ts.snap +++ b/__tests__/__snapshots__/refs.test.ts.snap @@ -168,3 +168,76 @@ export const petSchema = v.looseObject( }); " `; + +exports[`a schema that contains itself reads itself lazily > types.ts 1`] = ` +"export type Category = { + "name": string; + "children"?: readonly (Category)[]; + }; +" +`; + +exports[`a schema that contains itself reads itself lazily > valibot.ts 1`] = ` +"import * as v from "valibot"; +import type { Category } from "./types.js"; +import type { UndefinedOnPartialDeep } from "type-fest"; + +export const inputCategorySchema = v.looseObject( + { + "name": v.string() + , + "children": v.optional(v.array(v.lazy((): v.GenericSchema> => inputCategorySchema))) + , + }); +export const categorySchema = v.looseObject( + { + "name": v.pipe(v.string(), v.trim()) + , + "children": v.exactOptional(v.array(v.lazy((): v.GenericSchema => categorySchema))) + , + }); +" +`; + +exports[`schemas that refer to each other read the later one lazily > types.ts 1`] = ` +"export type Post = { + "author"?: Author; + "words"?: bigint; + }; +export type Author = { + "posts"?: readonly (Post)[]; + }; +" +`; + +exports[`schemas that refer to each other read the later one lazily > valibot.ts 1`] = ` +"import * as v from "valibot"; +import type { Author } from "./types.js"; +import type { UndefinedOnPartialDeep } from "type-fest"; + +export const inputPostSchema = v.looseObject( + { + "author": v.optional(v.lazy((): v.GenericSchema> => inputAuthorSchema)) + , + "words": v.optional(v.bigint()) + , + }); +export const postSchema = v.looseObject( + { + "author": v.exactOptional(v.lazy((): v.GenericSchema => authorSchema)) + , + "words": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()])) + , + }); +export const inputAuthorSchema = v.looseObject( + { + "posts": v.optional(v.array(inputPostSchema)) + , + }); +export const authorSchema = v.looseObject( + { + "posts": v.exactOptional(v.array(postSchema)) + , + }); +" +`; diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts index dc5b407..cd25649 100644 --- a/__tests__/refs.test.ts +++ b/__tests__/refs.test.ts @@ -173,3 +173,43 @@ test("a $ref into a part a schema lacks is refused", async () => { "#/components/schemas/Address/properties/postcode does not point at a schema in the document", ); }); + +test("a schema that contains itself reads itself lazily", async () => { + const result = await generateSchemas({ + Category: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + children: { + type: "array", + items: { $ref: "#/components/schemas/Category" }, + }, + }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); + +test("schemas that refer to each other read the later one lazily", async () => { + const result = await generateSchemas({ + Author: { + type: "object", + properties: { + posts: { type: "array", items: { $ref: "#/components/schemas/Post" } }, + }, + }, + Post: { + type: "object", + properties: { + author: { $ref: "#/components/schemas/Author" }, + words: { type: "integer", format: "int64" }, + }, + }, + }); + + expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index 642caa3..db678d0 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -47,6 +47,9 @@ import { import { createValibotFile, createValidatorForOperationInput, + declareValidator, + importLazyTypes, + type ValidatorEntry, registerValidatorFromSchema, addJsonValueSchemaWhenUsed, } from "./valibot.ts"; @@ -395,7 +398,7 @@ type DocumentContext = OutputFiles & { openapiVersion: string; typesImportDecl: ImportDeclaration; typesAndInterfaces: Map; - validators: Map; + validators: Map; allOperations: OperationMiddlewareInfo[]; outputTypes: Set; inputTypeArgs: Set; @@ -499,6 +502,37 @@ function ensureTypeImport( } } +// a ref that closes a loop is emitted lazily, so it drops out of the order +function withoutCycles(edges: [string, string][]) { + const next = Map.groupBy(edges, ([from]) => from); + const open = new Set(); + const done = new Set(); + const closing = new Set<[string, string]>(); + + const visit = (node: string) => { + open.add(node); + + for (const edge of next.get(node) ?? []) { + if (open.has(edge[1])) { + closing.add(edge); + } else if (!done.has(edge[1])) { + visit(edge[1]); + } + } + + open.delete(node); + done.add(node); + }; + + for (const [from] of edges) { + if (!done.has(from)) { + visit(from); + } + } + + return edges.filter((edge) => !closing.has(edge)); +} + function sortedComponentSchemas(schema: oas32.OpenAPIObject) { const schemas = Object.entries(schema.components?.schemas || {}); const defined = new Set(schemas.map(([schemaName]) => schemaRef(schemaName))); @@ -517,7 +551,7 @@ function sortedComponentSchemas(schema: oas32.OpenAPIObject) { return deps.map((dep): [string, string] => [schemaRef(schemaName), dep]); }); - const sorted = toposort(schemaGraph).toReversed(); + const sorted = toposort(withoutCycles(schemaGraph)).toReversed(); return schemas.toSorted( ([a], [b]) => sorted.indexOf(schemaRef(a)) - sorted.indexOf(schemaRef(b)), @@ -580,7 +614,13 @@ function registerComponentSchemas( documentCtx: DocumentContext, schema: oas32.OpenAPIObject, ) { - for (const [schemaName, schemaObject] of sortedComponentSchemas(schema)) { + const sorted = sortedComponentSchemas(schema); + + for (const [schemaName, schemaObject] of sorted) { + declareValidator(documentCtx.validators, schemaName, schemaObject); + } + + for (const [schemaName, schemaObject] of sorted) { registerTypesFromSchema( documentCtx.typesAndInterfaces, documentCtx.typesFile, @@ -2305,6 +2345,11 @@ export async function processOpenApiDocument( files.typesFile.fixUnusedIdentifiers(); files.commandsFile.fixUnusedIdentifiers(); files.commandsValidatedFile.fixUnusedIdentifiers(); + importLazyTypes( + files.valibotFile, + documentCtx.validators, + typesModuleSpecifierOf(files.typesFile), + ); addJsonValueSchemaWhenUsed(files.valibotFile); files.valibotFile.fixUnusedIdentifiers(); diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 97a01b7..e1c08c5 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -12,7 +12,7 @@ import { Writers, } from "ts-morph"; import { chunkOf } from "./chunks.ts"; -import { schemaRef } from "./refs.ts"; +import { schemaNameOf, schemaRef } from "./refs.ts"; import { type SchemaNode, type SchemaObject, @@ -225,19 +225,13 @@ function refType( schemaObject: oas32.ReferenceObject, ) { const existingSchema = typesAndInterfaces.get(schemaObject.$ref); - - // components register in dependency order, so a miss is a codegen bug - if (!existingSchema) { - throw new Error(`ref used before available: ${schemaObject.$ref}`); - } - - const docs = refPropertyDocs(existingSchema); + const docs = existingSchema ? refPropertyDocs(existingSchema) : []; const property: Pick< OptionalKind, "type" | "docs" > = { - type: existingSchema.getName(), + type: typeNameOf(typesAndInterfaces, schemaObject.$ref), ...(docs.length > 0 && { docs }), }; @@ -700,14 +694,11 @@ export function schemaToType( return property; } -function resolveRef(typesAndInterfaces: TypesAndInterfaces, ref: string) { - const declaration = typesAndInterfaces.get(ref); - - if (!declaration) { - throw new Error(`ref used before available: ${ref}`); - } - - return declaration; +// a recursive schema names an alias that is declared later +function typeNameOf(typesAndInterfaces: TypesAndInterfaces, ref: string) { + return ( + typesAndInterfaces.get(ref)?.getName() ?? pascalCase(schemaNameOf(ref)) + ); } function registerAlias( @@ -744,7 +735,7 @@ function combinatorAliasType( const typeAliases = schemaItems .filter((value) => isReferenceObject(value)) - .map((s) => resolveRef(typesAndInterfaces, s.$ref)); + .map((s) => typeNameOf(typesAndInterfaces, s.$ref)); const objectTypesFromNonRefSchemas = schemaItems .filter((value) => isSchemaObject(value)) @@ -780,7 +771,7 @@ function combinatorAliasType( // concat and dedupe const typeArgs = [ ...new Set([ - ...typeAliases.map((t) => t.getName()), + ...typeAliases, ...objectTypesFromNonRefSchemas, ...nonObjectTypesFromNonRefSchemas .map((t) => @@ -839,7 +830,7 @@ export function registerTypesFromSchema( // deal with refs if ("$ref" in schemaObject) { - register(resolveRef(typesAndInterfaces, schemaObject.$ref).getName()); + register(typeNameOf(typesAndInterfaces, schemaObject.$ref)); } // deal with unions and intersections @@ -930,7 +921,7 @@ export function registerTypesFromSchema( isReferenceObject(schemaObject.items) ) { register( - `${resolveRef(typesAndInterfaces, schemaObject.items.$ref).getName()}[]`, + `${typeNameOf(typesAndInterfaces, schemaObject.items.$ref)}[]`, schemaObject.description, ); } else { diff --git a/lib/refs.ts b/lib/refs.ts index 5bb2eba..6bd2122 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -10,6 +10,14 @@ export function schemaRef(schemaName: string) { return `${componentSchemas}${escapeToken(schemaName)}`; } +/** + * Recovers a component's name from a whole-component $ref, once refs are + * normalized + */ +export function schemaNameOf(ref: string) { + return unescapeToken(ref.slice(componentSchemas.length)); +} + function escapeToken(token: string) { return token.replaceAll("~", "~0").replaceAll("/", "~1"); } diff --git a/lib/valibot.ts b/lib/valibot.ts index 224018a..beddae2 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -17,6 +17,7 @@ import { type SchemaNode, type SchemaObject, camelCase, + pascalCase, isSchemaObject, typedEntries, wordWrap, @@ -25,11 +26,18 @@ import { // input uses `v.optional` and skips coercion, wire uses `v.exactOptional` type SchemaMode = "input" | "wire"; -type ValidatorEntry = { +export type ValidatorEntry = { input: string; wire: string; + + // the generated type, which annotates a lazy ref + type: string; + wireIsInput: boolean; + emitted: boolean; }; +const lazilyTyped = new WeakMap, Set>(); + // oxlint groups integer digits in threes once a literal reaches five digits function numericLiteral(value: number) { const text = String(value); @@ -252,12 +260,86 @@ function resolveRef( ) { const entry = validators.get(ref); - // components register in dependency order, so a miss is a codegen bug + // every component is declared before any is emitted if (!entry) { throw new Error(`ref used before available: ${ref}`); } - return mode === "input" ? entry.input : entry.wire; + const name = mode === "input" ? entry.input : entry.wire; + + if (entry.emitted) { + return name; + } + + // A recursive schema refers to one that is not declared yet. The + // annotation stops TypeScript inferring its type from itself + const types = lazilyTyped.get(validators) ?? new Set(); + types.add(entry.type); + lazilyTyped.set(validators, types); + + const type = + mode === "input" || entry.wireIsInput + ? `UndefinedOnPartialDeep<${entry.type}>` + : entry.type; + + // a wire schema coerces its input, so only the output type is given + return `v.lazy((): v.GenericSchema => ${name})`; +} + +/** + * Imports the types that annotate lazy refs. Call it once every validator + * is emitted + */ +export function importLazyTypes( + file: SourceFile, + validators: Map, + typesModuleSpecifier: string, +) { + const types = lazilyTyped.get(validators); + + if (!types) { + return; + } + + file.addImportDeclaration({ + moduleSpecifier: typesModuleSpecifier, + namedImports: [...types], + isTypeOnly: true, + }); + + importFromTypeFest(file, "UndefinedOnPartialDeep"); +} + +function importFromTypeFest(file: SourceFile, name: string) { + const existing = file.getImportDeclaration("type-fest"); + + if (existing) { + existing.addNamedImport(name); + } else { + file.addImportDeclaration({ + moduleSpecifier: "type-fest", + namedImports: [name], + isTypeOnly: true, + }); + } +} + +/** + * Names the validators of every component before any is emitted, so a + * recursive schema can refer to one emitted after it + */ +export function declareValidator( + validators: Map, + schemaName: string, + schemaObject: SchemaNode, +) { + validators.set(schemaRef(schemaName), { + input: camelCase("input", schemaName, "schema"), + wire: camelCase(schemaName, "schema"), + type: pascalCase(schemaName), + wireIsInput: !shouldCoerceSchema(schemaObject), + emitted: false, + }); } function writeStrictObjectEntries( @@ -817,11 +899,7 @@ export function addJsonValueSchemaWhenUsed(file: SourceFile) { return; } - file.addImportDeclaration({ - moduleSpecifier: "type-fest", - namedImports: ["JsonValue"], - isTypeOnly: true, - }); + importFromTypeFest(file, "JsonValue"); // A value a schema leaves open is checked as JSON, recursively, so both // sides type it as JsonValue @@ -862,13 +940,13 @@ export function registerValidatorFromSchema( schemaObject: SchemaNode, inputOnly?: boolean, ) { - const inputName = camelCase("input", schemaName, "schema"); - const wireName = camelCase(schemaName, "schema"); + const entry = validators.get(schemaRef(schemaName)); - validators.set(schemaRef(schemaName), { - input: inputName, - wire: wireName, - }); + if (!entry) { + throw new Error(`${schemaName} is emitted before it is declared`); + } + + const { input: inputName, wire: wireName } = entry; const docs = isSchemaObject(schemaObject) && schemaObject.description @@ -946,6 +1024,8 @@ export function registerValidatorFromSchema( }); } } + + entry.emitted = true; } // Coerces HTTP param strings to native values, leaving other types alone From 19f548ae0220e78208a781ceb94d92ef3034641f Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:07:09 +0800 Subject: [PATCH 7/8] refactor: resolve $refs locally, without json-schema-ref-parser Codegen used the ref parser only to look up refs inside the document it was given, but resolve() also followed refs to other files and URLs, reading and fetching them at codegen time. A ref is now looked up in the in-memory document with the same JSON pointer decoding the schema registry uses, and a ref outside it is refused by name. Optional: this commit stands alone and can be dropped. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- __tests__/refs.test.ts | 20 +++++++++++ lib/process-document.ts | 34 ++++++++----------- lib/refs.ts | 20 +++++++++++ package.json | 1 - pnpm-lock.yaml | 15 -------- 9 files changed, 59 insertions(+), 39 deletions(-) diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 80ce38f..15a3b80 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "0be4b85c81ade4ffa55403d163b5dafe", + "#generator": "902b2a6ffdfff666fa22307ed7223e46", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 6665bc1..ece0e7e 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "0be4b85c81ade4ffa55403d163b5dafe", + "#generator": "902b2a6ffdfff666fa22307ed7223e46", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 3551e9a..cffacba 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "0be4b85c81ade4ffa55403d163b5dafe", + "#generator": "902b2a6ffdfff666fa22307ed7223e46", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 9f223b3..b1aa918 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "0be4b85c81ade4ffa55403d163b5dafe", + "#generator": "902b2a6ffdfff666fa22307ed7223e46", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/__tests__/refs.test.ts b/__tests__/refs.test.ts index cd25649..2a28769 100644 --- a/__tests__/refs.test.ts +++ b/__tests__/refs.test.ts @@ -213,3 +213,23 @@ test("schemas that refer to each other read the later one lazily", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); }); + +test("a $ref to another file is refused without reading it", async () => { + await expect( + processOpenApiDocument("/tmp/refs", { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/orders": { + get: { + operationId: "listOrdersCommand", + parameters: [{ $ref: "shared.yaml#/components/parameters/Page" }], + responses: { "204": { description: "No content" } }, + }, + }, + }, + }), + ).rejects.toThrow( + "shared.yaml#/components/parameters/Page is not in the document, and only local refs are read", + ); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index db678d0..355a27f 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -1,5 +1,4 @@ import nodePath from "node:path"; -import { $RefParser, type $Refs } from "@apidevtools/json-schema-ref-parser"; import type { oas30, oas32 } from "openapi3-ts"; import toposort from "toposort"; import { @@ -29,7 +28,7 @@ import { queryStyles, } from "./hono.ts"; import { registerTypesFromSchema, schemaToType } from "./process-schema.ts"; -import { normalizeRefs, schemaRef } from "./refs.ts"; +import { localRefs, normalizeRefs, type Refs, schemaRef } from "./refs.ts"; import { type ReferenceObject, type SchemaNode, @@ -312,7 +311,7 @@ const sequentialMediaTypes: Readonly> = { // a $ref resolves to the object it names, which the document must hold function resolveObject( - refs: $Refs, + refs: Refs, node: T | ReferenceObject, ) { if (!isReferenceObject(node)) { @@ -330,7 +329,7 @@ function resolveObject( } // OAS 3.2 lets a media type be a $ref, so a content map resolves before use -function resolveContent(refs: $Refs, content: oas32.ContentObject | undefined) { +function resolveContent(refs: Refs, content: oas32.ContentObject | undefined) { return Object.fromEntries( Object.entries(content ?? {}).map(([mediaType, media]) => [ mediaType, @@ -394,7 +393,7 @@ function createOutputFiles(project: Project, outputDir: string) { type OutputFiles = ReturnType; type DocumentContext = OutputFiles & { - refs: $Refs; + refs: Refs; openapiVersion: string; typesImportDecl: ImportDeclaration; typesAndInterfaces: Map; @@ -701,8 +700,12 @@ function declareCommandClass( return { commandName, commandClass, deprecationDocs }; } +function isObjectSchema(value: unknown): value is oas30.SchemaObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + // valibot coercion inspects the schema, so a $ref has to go first -function withResolvedSchema(refs: $Refs, parameter: oas30.ParameterObject) { +function withResolvedSchema(refs: Refs, parameter: oas30.ParameterObject) { const resolvedSchema = parameter.schema && "$ref" in parameter.schema ? (refs.get(parameter.schema.$ref) ?? undefined) @@ -710,18 +713,14 @@ function withResolvedSchema(refs: $Refs, parameter: oas30.ParameterObject) { const paramWithResolvedSchema: oas30.ParameterObject = { ...parameter, - ...(resolvedSchema && - typeof resolvedSchema === "object" && - !Array.isArray(resolvedSchema) && { - schema: resolvedSchema, - }), + ...(isObjectSchema(resolvedSchema) && { schema: resolvedSchema }), }; return paramWithResolvedSchema; } function collectParameters( - refs: $Refs, + refs: Refs, path: string, pathItemObject: oas32.PathItemObject, operationObject: OperationWithId, @@ -1242,10 +1241,7 @@ function firstSuccessResponse(operationObject: OperationWithId) { return response; } -function firstJsonResponseSchema( - refs: $Refs, - operationObject: OperationWithId, -) { +function firstJsonResponseSchema(refs: Refs, operationObject: OperationWithId) { // Resolve the first 2xx JSON response schema (inline or $ref) so the // validator pipeline treats responses the same as request bodies return resolveContent(refs, firstSuccessResponse(operationObject)?.content)[ @@ -1443,7 +1439,7 @@ function addInlineOutput( }); } -function resolveSchema(refs: $Refs, operationId: string, schema: SchemaNode) { +function resolveSchema(refs: Refs, operationId: string, schema: SchemaNode) { if (typeof schema === "boolean") { throw new TypeError( `${operationId}: a boolean item schema has no content to decode`, @@ -1461,7 +1457,7 @@ function canStateItemSchema(openapiVersion: string) { } function sequentialContent( - refs: $Refs, + refs: Refs, openapiVersion: string, operationObject: OperationWithId, ) { @@ -2311,7 +2307,7 @@ export async function processOpenApiDocument( const schema = normalizeRefs(document); const project = new Project(); const files = createOutputFiles(project, outputDir); - const refs = await $RefParser.resolve(schema); + const refs = localRefs(schema); const typesImportDecl = addModulePreambles(files); const documentCtx: DocumentContext = { diff --git a/lib/refs.ts b/lib/refs.ts index 6bd2122..99d8872 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -77,6 +77,26 @@ function lookup(document: unknown, ref: string) { return node; } +export type Refs = { get(ref: string): unknown }; + +/** + * Looks up $refs inside the document. A ref to another file or a URL is + * refused, which keeps generation to the one document it is given + */ +export function localRefs(document: unknown): Refs { + return { + get: (ref) => { + if (!ref.startsWith("#")) { + throw new Error( + `${ref} is not in the document, and only local refs are read`, + ); + } + + return lookup(document, ref); + }, + }; +} + /** * Leaves only $refs the generator can look up. A percent-encoded ref is * decoded. A ref into part of a component schema is replaced by that diff --git a/package.json b/package.json index 25aeac9..0492ee2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "preversion": "just check" }, "dependencies": { - "@apidevtools/json-schema-ref-parser": "^16.0.3", "camelcase": "^9.0.0", "toposort": "^2.0.2", "ts-morph": "^28.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a07ff88..0b369ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,9 +170,6 @@ importers: .: dependencies: - '@apidevtools/json-schema-ref-parser': - specifier: ^16.0.3 - version: 16.0.3(@types/json-schema@7.0.15) camelcase: specifier: ^9.0.0 version: 9.0.0 @@ -252,12 +249,6 @@ importers: packages: - '@apidevtools/json-schema-ref-parser@16.0.3': - resolution: {integrity: sha512-7lZp4XCTZhU/AgiGpIG+j4uJeWPdGbpQ+8wqXrzuHy+tboKsjSacjGH9/kNThRDIFhMWHF3ba+ejS8J3PeSI1Q==} - engines: {node: '>=22.19.0'} - peerDependencies: - '@types/json-schema': ^7.0.15 - '@block65/custom-error@14.1.0': resolution: {integrity: sha512-smbcm4A4TwTwKDxJz639scvwCSVtU8qmk/Em2n9P5NT2JLGeK/beRI8acUS/yuAK04LYQrjfDqUFSva4472OcQ==} @@ -1664,12 +1655,6 @@ packages: snapshots: - '@apidevtools/json-schema-ref-parser@16.0.3(@types/json-schema@7.0.15)': - dependencies: - '@types/json-schema': 7.0.15 - js-yaml: 5.4.2 - undici: 8.11.2 - '@block65/custom-error@14.1.0': dependencies: serialize-error: 13.0.1 From c746e50a73546b03a265a14c15b2f2cdd49f915b Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:28:43 +0800 Subject: [PATCH 8/8] refactor: type the schema keyword lists against OAS 3.2 A misspelled keyword now fails typecheck instead of silently never matching. getDependents reads each keyword through the schema's own types. additionalItems and definitions are dropped: OAS 3.2 does not define them. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- lib/refs.ts | 11 ++-- lib/utils.ts | 50 +++++++++---------- 6 files changed, 34 insertions(+), 35 deletions(-) diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 15a3b80..5173c6d 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "902b2a6ffdfff666fa22307ed7223e46", + "#generator": "06eb706c73e16a15cba65397366f0e38", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index ece0e7e..44aabd1 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "902b2a6ffdfff666fa22307ed7223e46", + "#generator": "06eb706c73e16a15cba65397366f0e38", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index cffacba..e42634b 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "902b2a6ffdfff666fa22307ed7223e46", + "#generator": "06eb706c73e16a15cba65397366f0e38", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index b1aa918..66a0da5 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "902b2a6ffdfff666fa22307ed7223e46", + "#generator": "06eb706c73e16a15cba65397366f0e38", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/lib/refs.ts b/lib/refs.ts index 99d8872..4b1ba1e 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -1,4 +1,5 @@ import type { oas32 } from "openapi3-ts"; +import type { SchemaKeyword } from "./utils.ts"; const componentSchemas = "#/components/schemas/"; @@ -23,22 +24,22 @@ function escapeToken(token: string) { } // a `$ref` key inside these is data, unless the key names a property -const dataKeywords = new Set([ +const dataKeywords: ReadonlySet = new Set([ "example", "examples", "default", "const", "enum", -]); +] as const satisfies readonly SchemaKeyword[]); -const nameMapKeywords = new Set([ +// components.schemas, and the schema keywords that map names to subschemas +const nameMapKeywords: ReadonlySet = new Set([ "schemas", "properties", "patternProperties", "dependentSchemas", "$defs", - "definitions", -]); +] as const satisfies readonly (keyof oas32.ComponentsObject | SchemaKeyword)[]); function decodeFragment(ref: string) { try { diff --git a/lib/utils.ts b/lib/utils.ts index c956031..630d829 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,6 @@ import camelcase from "camelcase"; import type { oas30, oas32 } from "openapi3-ts"; +import type { OmitIndexSignature } from "type-fest"; import wrap from "word-wrap"; export type SchemaObject = oas30.SchemaObject | oas32.SchemaObjectValue; @@ -21,16 +22,20 @@ export function isNotNullOrUndefined(obj: T | null | undefined): obj is T { return obj !== null && obj !== undefined; } +// the keywords OAS 3.2 names, without the index signature that admits any +// string +export type SchemaKeyword = keyof OmitIndexSignature; + // each keyword holds a subschema, or an array of them -const subschemaKeywords = new Set([ +const subschemaKeywords = [ "items", "prefixItems", - "additionalItems", "additionalProperties", "unevaluatedItems", "unevaluatedProperties", "propertyNames", "contains", + "contentSchema", "not", "if", "then", @@ -38,48 +43,41 @@ const subschemaKeywords = new Set([ "allOf", "anyOf", "oneOf", -]); +] as const satisfies readonly SchemaKeyword[]; // each keyword maps names to subschemas -const subschemaMapKeywords = new Set([ +const subschemaMapKeywords = [ "properties", "patternProperties", "dependentSchemas", "$defs", - "definitions", -]); +] as const satisfies readonly SchemaKeyword[]; /** * Every $ref a schema depends on, at any depth. Only subschemas count. An * example, default, const or enum is data, and a `$ref` key inside it is not * a reference */ -export function getDependents(schema: unknown): string[] { - if (isReferenceObject(schema)) { - return [schema.$ref]; - } - - if (typeof schema !== "object" || schema === null) { +export function getDependents( + schema: oas32.SchemaObject | oas32.ReferenceObject, +): string[] { + if (typeof schema === "boolean") { return []; } - const entries = Object.entries(schema); + if (isReferenceObject(schema)) { + return [schema.$ref]; + } return [ - ...entries - .filter(([keyword]) => subschemaKeywords.has(keyword)) - .flatMap(([, value]) => - Array.isArray(value) - ? value.flatMap((item) => getDependents(item)) - : getDependents(value), - ), - ...entries - .filter(([keyword]) => subschemaMapKeywords.has(keyword)) - .flatMap(([, value]) => - typeof value === "object" && value !== null - ? Object.values(value).flatMap((item) => getDependents(item)) - : [], + ...subschemaKeywords.flatMap((keyword) => + [schema[keyword] ?? []].flat().flatMap((item) => getDependents(item)), + ), + ...subschemaMapKeywords.flatMap((keyword) => + Object.values(schema[keyword] ?? {}).flatMap((item) => + getDependents(item), ), + ), ]; }