diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index eb22801f1..a292678b6 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -1,7 +1,16 @@ import * as v from 'valibot' import * as z from 'zod' +import { $ZodRegistry, toJSONSchema } from 'zod/v4/core' import { ZodToJsonSchemaConverter } from './converter' +vi.mock('zod/v4/core', async (original) => { + const mod = await original() + return { + ...mod, + toJSONSchema: vi.fn((...args: [any]) => mod.toJSONSchema(...args)), + } +}) + describe('zodToJsonSchemaConverter', () => { const converter = new ZodToJsonSchemaConverter() const codecSchema = z.codec(z.string(), z.number(), { @@ -65,6 +74,100 @@ describe('zodToJsonSchemaConverter', () => { expect(converter.convert(schema, 'input')).toEqual([{ type: 'string' }, false]) }) + describe('supports $ref at root level', () => { + it('with the global metadata registry and special json pointers', () => { + const schema = z.object({ + a: z.string().meta({ id: 'a' }), + b: z.number().meta({ id: 'b' }), + }).meta({ id: 'root~/' }) + + expect(converter.convert(schema, 'input')).toEqual([{ + $ref: '#/$defs/root~0~1', + $defs: { + 'a': { + type: 'string', + }, + 'b': { + type: 'number', + }, + 'root~/': { + type: 'object', + properties: { + a: { + $ref: '#/$defs/a', + }, + b: { + $ref: '#/$defs/b', + }, + }, + required: [ + 'a', + 'b', + ], + }, + }, + }, false], + ) + }) + + it('with a custom metadata registry', () => { + const registry = new $ZodRegistry() + const customConverter = new ZodToJsonSchemaConverter({ metadata: registry as any }) + + const schema = z.object({ + a: z.string(), + }) + + registry.add(schema, { id: 'root' }) + registry.add(schema.shape.a, { id: 'a' }) + + expect(customConverter.convert(schema, 'input')).toEqual([{ + $ref: '#/$defs/root', + $defs: { + a: { + type: 'string', + }, + root: { + type: 'object', + properties: { + a: { + $ref: '#/$defs/a', + }, + }, + required: [ + 'a', + ], + }, + }, + }, false]) + }) + + it('avoids overwriting existing $defs when the root id collides', () => { + const schema = z.string().meta({ id: 'root' }) + + vi.mocked(toJSONSchema).mockReturnValueOnce({ + $defs: { + root: { + type: 'number', + }, + }, + type: 'string', + } as any) + + expect(converter.convert(schema, 'input')).toEqual([{ + $ref: '#/$defs/root__0', + $defs: { + root: { + type: 'number', + }, + root__0: { + type: 'string', + }, + }, + }, false]) + }) + }) + describe('optionality', () => { it.each([ ['defaulted input schema', z.string().default('fallback'), 'input', { diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 755609d24..e06f11a80 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -1,7 +1,7 @@ import type { AnySchema, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' import type { $ZodType, ToJSONSchemaParams, JSONSchema as ZodJsonSchema } from 'zod/v4/core' -import { JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema' -import { toJSONSchema } from 'zod/v4/core' +import { encodeJsonPointerSegment, JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema' +import { globalRegistry, toJSONSchema } from 'zod/v4/core' export interface ZodToJsonSchemaConverterOptions extends Omit {} @@ -75,6 +75,28 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { // Since the default oRPC format is always draft/2020-12, // `$schema` can be safely omitted here. const { $schema, ...rest } = jsonSchema + + // workaround until https://github.com/colinhacks/zod/issues/6026 is merged + const registry = this.options.metadata ?? globalRegistry + const { id } = registry.get(schema) || {} + if (typeof id === 'string' && rest.$ref === undefined) { + const { $defs = {}, ...restWithoutDefs } = rest + + let defName = id + let index = 0 + while (defName in $defs) { + defName = `${defName}__${index++}` + } + + return { + $ref: `#/$defs/${encodeJsonPointerSegment(defName)}`, + $defs: { + ...$defs, + [defName]: restWithoutDefs, + }, + } + } + return rest } }