diff --git a/__tests__/__snapshots__/refs.test.ts.snap b/__tests__/__snapshots__/refs.test.ts.snap new file mode 100644 index 0000000..6c6830e --- /dev/null +++ b/__tests__/__snapshots__/refs.test.ts.snap @@ -0,0 +1,243 @@ +// 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 $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 = { + "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) + , + }); +" +`; + +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) + , + }); +" +`; + +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__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 2a95ecc..5173c6d 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#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 039ee59..44aabd1 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#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 b4f3c70..e42634b 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#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 f397e5b..66a0da5 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "73e95b200e00f1d54784476e106a00fd", + "#generator": "06eb706c73e16a15cba65397366f0e38", "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..2a28769 --- /dev/null +++ b/__tests__/refs.test.ts @@ -0,0 +1,235 @@ +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"); +}); + +test("a $ref to an undefined schema names where it is made", async () => { + await expect( + generateSchemas({ + Order: { + type: "object", + properties: { customer: { $ref: "#/components/schemas/Customer" } }, + }, + }), + ).rejects.toThrow( + "#/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", + ); +}); + +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", + ); +}); + +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"); +}); + +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/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..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 { @@ -20,6 +19,7 @@ import { Writers, } from "ts-morph"; import type { Simplify } from "type-fest"; +import { chunkOf, joinChunks } from "./chunks.ts"; import { addSchemaImportsToHonoFile, createHonoFile, @@ -28,6 +28,7 @@ import { queryStyles, } from "./hono.ts"; import { registerTypesFromSchema, schemaToType } from "./process-schema.ts"; +import { localRefs, normalizeRefs, type Refs, schemaRef } from "./refs.ts"; import { type ReferenceObject, type SchemaNode, @@ -45,6 +46,9 @@ import { import { createValibotFile, createValidatorForOperationInput, + declareValidator, + importLazyTypes, + type ValidatorEntry, registerValidatorFromSchema, addJsonValueSchemaWhenUsed, } from "./valibot.ts"; @@ -307,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)) { @@ -325,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, @@ -389,11 +393,11 @@ function createOutputFiles(project: Project, outputDir: string) { type OutputFiles = ReturnType; type DocumentContext = OutputFiles & { - refs: $Refs; + refs: Refs; openapiVersion: string; typesImportDecl: ImportDeclaration; typesAndInterfaces: Map; - validators: Map; + validators: Map; allOperations: OperationMiddlewareInfo[]; outputTypes: Set; inputTypeArgs: Set; @@ -497,24 +501,59 @@ 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))); 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}`, - dep, - ]); + 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(`#/components/schemas/${a}`) - - sorted.indexOf(`#/components/schemas/${b}`), + ([a], [b]) => sorted.indexOf(schemaRef(a)) - sorted.indexOf(schemaRef(b)), ); } @@ -574,7 +613,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, @@ -618,7 +663,7 @@ function declareCommandClass( "Command", ); - const commandClass = commandsFile.addClass({ + const commandClass = chunkOf(commandsFile).addClass({ name: commandName, isExported: true, extends: "Command", @@ -655,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) @@ -664,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, @@ -903,7 +948,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 +979,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 +1042,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 +1080,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 +1121,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 +1173,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 +1196,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 +1212,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, @@ -1194,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)[ @@ -1355,7 +1399,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 +1418,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,14 +1432,14 @@ 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, }); } -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`, @@ -1413,7 +1457,7 @@ function canStateItemSchema(openapiVersion: string) { } function sequentialContent( - refs: $Refs, + refs: Refs, openapiVersion: string, operationObject: OperationWithId, ) { @@ -1529,7 +1573,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 +2024,7 @@ function processOperation( registerValidatedCommand(documentCtx, operationCtx.commandName, wireSchemas); addQueryAndHeaderTypeArguments(operationCtx); addCommandConstructor(operationCtx, path); + trimDefaultOutputArgument(command.commandClass); } function emitOperations( @@ -2240,30 +2285,29 @@ 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); } } 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); + const refs = localRefs(schema); const typesImportDecl = addModulePreambles(files); const documentCtx: DocumentContext = { @@ -2287,6 +2331,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(); @@ -2294,6 +2341,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(); @@ -2303,7 +2355,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..e1c08c5 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -11,6 +11,8 @@ import { type WriterFunction, Writers, } from "ts-morph"; +import { chunkOf } from "./chunks.ts"; +import { schemaNameOf, schemaRef } from "./refs.ts"; import { type SchemaNode, type SchemaObject, @@ -223,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 }), }; @@ -698,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( @@ -715,7 +708,7 @@ function registerAlias( type: string | WriterFunction, description?: string, ) { - const typeAlias = typesFile.addTypeAlias({ + const typeAlias = chunkOf(typesFile).addTypeAlias({ name: pascalCase(schemaName), isExported: true, type, @@ -727,7 +720,7 @@ function registerAlias( }); } - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + typesAndInterfaces.set(schemaRef(schemaName), typeAlias); } function combinatorAliasType( @@ -742,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)) @@ -778,7 +771,7 @@ function combinatorAliasType( // concat and dedupe const typeArgs = [ ...new Set([ - ...typeAliases.map((t) => t.getName()), + ...typeAliases, ...objectTypesFromNonRefSchemas, ...nonObjectTypesFromNonRefSchemas .map((t) => @@ -837,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 @@ -888,14 +881,14 @@ 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))), docs, }); - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, stringUnion); + typesAndInterfaces.set(schemaRef(schemaName), stringUnion); } // deal with non-enum strings @@ -928,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 new file mode 100644 index 0000000..4b1ba1e --- /dev/null +++ b/lib/refs.ts @@ -0,0 +1,194 @@ +import type { oas32 } from "openapi3-ts"; +import type { SchemaKeyword } from "./utils.ts"; + +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}${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"); +} + +// a `$ref` key inside these is data, unless the key names a property +const dataKeywords: ReadonlySet = new Set([ + "example", + "examples", + "default", + "const", + "enum", +] as const satisfies readonly SchemaKeyword[]); + +// components.schemas, and the schema keywords that map names to subschemas +const nameMapKeywords: ReadonlySet = new Set([ + "schemas", + "properties", + "patternProperties", + "dependentSchemas", + "$defs", +] as const satisfies readonly (keyof oas32.ComponentsObject | SchemaKeyword)[]); + +function decodeFragment(ref: string) { + try { + return decodeURIComponent(ref); + } catch { + return ref; + } +} + +function unescapeToken(token: string) { + return token.replaceAll("~1", "/").replaceAll("~0", "~"); +} + +function pointerTokens(ref: string) { + return ref + .slice(2) + .split("/") + .map((token) => unescapeToken(token)); +} + +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; +} + +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 + * subschema, so each schema ref left names a whole component + */ +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, index) => + visit(item, false, inlining, `${at}/${index}`), + ); + } + + 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("/"); + + 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 }; + } + + 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], 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, `${at}/${escapeToken(key)}`), + ]), + ), + } + : inlined; + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + !inNameMap && dataKeywords.has(key) + ? value + : 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 oas32.OpenAPIObject; +} diff --git a/lib/utils.ts b/lib/utils.ts index 9a0208c..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,29 +22,75 @@ 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 = [ + "items", + "prefixItems", + "additionalProperties", + "unevaluatedItems", + "unevaluatedProperties", + "propertyNames", + "contains", + "contentSchema", + "not", + "if", + "then", + "else", + "allOf", + "anyOf", + "oneOf", +] as const satisfies readonly SchemaKeyword[]; + +// each keyword maps names to subschemas +const subschemaMapKeywords = [ + "properties", + "patternProperties", + "dependentSchemas", + "$defs", +] as const satisfies readonly SchemaKeyword[]; + /** - * 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: oas32.SchemaObject | oas32.ReferenceObject, +): string[] { + if (typeof schema === "boolean") { + return []; } - if (typeof obj !== "object" || obj === null) { - return []; + if (isReferenceObject(schema)) { + return [schema.$ref]; } - return Object.values(obj).flatMap((value) => getDependents(value)); + return [ + ...subschemaKeywords.flatMap((keyword) => + [schema[keyword] ?? []].flat().flatMap((item) => getDependents(item)), + ), + ...subschemaMapKeywords.flatMap((keyword) => + Object.values(schema[keyword] ?? {}).flatMap((item) => + getDependents(item), + ), + ), + ]; } +// 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 07d7413..beddae2 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, @@ -12,9 +11,13 @@ import { } from "ts-morph"; 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, + pascalCase, isSchemaObject, typedEntries, wordWrap, @@ -23,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); @@ -250,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( @@ -815,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 @@ -860,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(`#/components/schemas/${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 @@ -903,7 +983,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 +1001,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 +1012,7 @@ export function registerValidatorFromSchema( ], }); } else { - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ @@ -944,6 +1024,8 @@ export function registerValidatorFromSchema( }); } } + + entry.emitted = true; } // Coerces HTTP param strings to native values, leaving other types alone @@ -1078,10 +1160,10 @@ 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"); - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ @@ -1093,7 +1175,7 @@ function emitNamePair( }); if (!inputOnly) { - valibotFile.addVariableStatement({ + chunkOf(valibotFile).addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ 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