diff --git a/packages/openapi-ts-tests/valibot/v1/__snapshots__/3.1.x/validators-acyclic-ref-order/valibot.gen.ts b/packages/openapi-ts-tests/valibot/v1/__snapshots__/3.1.x/validators-acyclic-ref-order/valibot.gen.ts new file mode 100644 index 0000000000..0980e554d4 --- /dev/null +++ b/packages/openapi-ts-tests/valibot/v1/__snapshots__/3.1.x/validators-acyclic-ref-order/valibot.gen.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as v from 'valibot'; + +export const vChild = v.object({ + id: v.optional(v.number()) +}); + +export const vParent = v.object({ + id: v.number(), + children: v.optional(v.array(vChild)) +}); diff --git a/packages/openapi-ts-tests/valibot/v1/test/3.1.x.test.ts b/packages/openapi-ts-tests/valibot/v1/test/3.1.x.test.ts index 69ce20ed7d..43074058f6 100644 --- a/packages/openapi-ts-tests/valibot/v1/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/valibot/v1/test/3.1.x.test.ts @@ -87,6 +87,13 @@ describe(`OpenAPI ${version}`, () => { }), description: 'validator schemas with BigInt and min/max constraints', }, + { + config: createConfig({ + input: 'validators-acyclic-ref-order.yaml', + output: 'validators-acyclic-ref-order', + }), + description: 'orders acyclic validator schema references before dependents', + }, { config: createConfig({ input: 'validators-circular-ref.json', diff --git a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts index fc335177d8..a065e901eb 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts @@ -1,6 +1,12 @@ import { ref } from '@hey-api/codegen-core'; -import type { SchemaExtractor } from '@hey-api/shared'; -import { createSchemaProcessor, createSchemaWalker, pathToJsonPointer } from '@hey-api/shared'; +import type { IR, SchemaExtractor } from '@hey-api/shared'; +import { + createSchemaProcessor, + createSchemaWalker, + jsonPointerToPath, + normalizeJsonPointer, + pathToJsonPointer, +} from '@hey-api/shared'; import { exportAst } from '../shared/export'; import type { ProcessorContext, ProcessorResult } from '../shared/processor'; @@ -13,6 +19,54 @@ export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorRes const extractorHooks = plugin.getHooks((hooks) => hooks.schemas?.shouldExtract); + function isCyclicReference(currentPointer: string, targetPointer: string): boolean { + if (targetPointer === currentPointer) { + return true; + } + + return ( + plugin.context.graph?.transitiveDependencies.get(targetPointer)?.has(currentPointer) ?? false + ); + } + + function ensureReferenceRegistered(currentPointer: string, $ref: string): void { + const targetPointer = normalizeJsonPointer($ref); + const query = { + artifact: 'valibot', + category: 'schema', + resource: 'definition', + resourceId: targetPointer, + }; + + if (plugin.isSymbolRegistered(query)) { + return; + } + + if (isCyclicReference(currentPointer, targetPointer)) { + return; + } + + const targetSchema = plugin.context.resolveIrRef(targetPointer); + const targetPath = jsonPointerToPath(targetPointer); + const targetInfo = plugin.context.graph?.nodes.get(targetPointer); + + process({ + meta: { + resource: 'definition', + resourceId: targetPointer, + }, + naming: plugin.config.definitions, + path: targetPath, + plugin, + schema: targetSchema, + tags: targetInfo?.tags ? Array.from(targetInfo.tags) : undefined, + }); + + if (!plugin.isSymbolRegistered(query)) { + throw new Error(`Failed to emit acyclic Valibot schema reference "${$ref}"`); + } + } + const schemaExtractor: SchemaExtractor = (ctx) => { if (processor.hasEmitted(ctx.path)) { return ctx.schema; @@ -39,7 +93,12 @@ export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorRes const shouldExport = ctx.export !== false; return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { - const visitor = createVisitor({ plugin, schemaExtractor }); + const currentPointer = pathToJsonPointer(ctx.path); + const visitor = createVisitor({ + ensureReferenceRegistered: ($ref) => ensureReferenceRegistered(currentPointer, $ref), + plugin, + schemaExtractor, + }); const walk = createSchemaWalker(visitor); const result = walk(ctx.schema, { diff --git a/packages/openapi-ts/src/plugins/valibot/v1/visitor.ts b/packages/openapi-ts/src/plugins/valibot/v1/visitor.ts index 573b92745d..b950555e30 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/visitor.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/visitor.ts @@ -1,7 +1,7 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import { fromRef } from '@hey-api/codegen-core'; import type { SchemaExtractor, SchemaVisitor } from '@hey-api/shared'; -import { pathToJsonPointer } from '@hey-api/shared'; +import { normalizeJsonPointer, pathToJsonPointer } from '@hey-api/shared'; import { $ } from '../../../ts-dsl'; import { maybeBigInt } from '../../shared/utils/coerce'; @@ -27,6 +27,8 @@ import { unknownToPipes } from './toAst/unknown'; import { voidToPipes } from './toAst/void'; export interface VisitorConfig { + /** Ensures an acyclic referenced schema has been emitted before using it directly. */ + ensureReferenceRegistered?: ($ref: string) => void; /** The plugin instance. */ plugin: ValibotPlugin['Instance']; /** Optional schema extractor function. */ @@ -40,7 +42,7 @@ function getDefaultValue(meta: ValibotMeta): ReturnType { export function createVisitor( config: VisitorConfig, ): SchemaVisitor { - const { plugin, schemaExtractor } = config; + const { ensureReferenceRegistered, plugin, schemaExtractor } = config; const v = plugin.imports.v; @@ -251,15 +253,16 @@ export function createVisitor( }; }, reference($ref, schema) { + const resourceId = normalizeJsonPointer($ref); const query: SymbolMeta = { artifact: 'valibot', category: 'schema', resource: 'definition', - resourceId: $ref, + resourceId, }; + ensureReferenceRegistered?.($ref); // TODO: contract (self) const refSymbol = plugin.referenceSymbol(query); - // TODO: contract (self) const isRegistered = plugin.isSymbolRegistered(query); if (isRegistered) { diff --git a/specs/3.1.x/validators-acyclic-ref-order.yaml b/specs/3.1.x/validators-acyclic-ref-order.yaml new file mode 100644 index 0000000000..79ae3bd841 --- /dev/null +++ b/specs/3.1.x/validators-acyclic-ref-order.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1.0 validators acyclic reference order example + version: 1 +paths: {} +components: + schemas: + Parent: + type: object + required: + - id + properties: + id: + type: number + children: + type: array + items: + $ref: '#/components/schemas/Child' + Child: + type: object + properties: + id: + type: number