Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/zod/src/converter.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('zod/v4/core')>()
return {
...mod,
toJSONSchema: vi.fn((...args: [any]) => mod.toJSONSchema(...args)),
}
})

describe('zodToJsonSchemaConverter', () => {
const converter = new ZodToJsonSchemaConverter()
const codecSchema = z.codec(z.string(), z.number(), {
Expand Down Expand Up @@ -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', {
Expand Down
26 changes: 24 additions & 2 deletions packages/zod/src/converter.ts
Original file line number Diff line number Diff line change
@@ -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<ToJSONSchemaParams, 'target' | 'io'> {}

Expand Down Expand Up @@ -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) {
Comment thread
dinwwwh marked this conversation as resolved.
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
}
}
Loading