From 361fb604085f125671a8d6215b9dac532175a67c Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 3 May 2025 19:56:52 +0700 Subject: [PATCH 01/13] wip --- packages/zod/package.json | 25 +- packages/zod/src/v4/converter.ts | 587 ++++++++++++++++++++++++++++++ packages/zod/src/v4/index.ts | 2 + packages/zod/src/v4/registries.ts | 60 +++ pnpm-lock.yaml | 28 +- 5 files changed, 698 insertions(+), 4 deletions(-) create mode 100644 packages/zod/src/v4/converter.ts create mode 100644 packages/zod/src/v4/index.ts create mode 100644 packages/zod/src/v4/registries.ts diff --git a/packages/zod/package.json b/packages/zod/package.json index 027f75f22..8d1a2eff5 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -19,11 +19,17 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" + }, + "./v4": { + "types": "./dist/v4/index.d.mts", + "import": "./dist/v4/index.mjs", + "default": "./dist/v4/index.mjs" } } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./v4": "./src/v4/index.ts" }, "files": [ "dist" @@ -36,7 +42,16 @@ "peerDependencies": { "@orpc/contract": "workspace:*", "@orpc/server": "workspace:*", - "zod": "^3.24.2" + "@zod/core": ">=0.10.0", + "zod": ">=3.24.2" + }, + "peerDependenciesMeta": { + "@zod/core": { + "optional": true + }, + "zod": { + "optional": true + } }, "dependencies": { "@orpc/openapi": "workspace:*", @@ -45,6 +60,10 @@ "wildcard-match": "^5.1.3" }, "devDependencies": { - "zod-to-json-schema": "^3.24.5" + "@zod/core": "^0.10.0", + "@zod/mini": "^@zod/mini@4.0.0-beta.20250503T014749", + "zod": "^3.24.2", + "zod-to-json-schema": "^3.24.5", + "zod4": "npm:zod@^4.0.0-beta.20250503T014749" } } diff --git a/packages/zod/src/v4/converter.ts b/packages/zod/src/v4/converter.ts new file mode 100644 index 000000000..42607c954 --- /dev/null +++ b/packages/zod/src/v4/converter.ts @@ -0,0 +1,587 @@ +import type { AnySchema } from '@orpc/contract' +import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from '@orpc/openapi' +import type { Interceptor, Promisable, ThrowableError } from '@orpc/shared' +import type { + $ZodArray, + $ZodCatch, + $ZodDefault, + $ZodEnum, + $ZodFile, + $ZodInterface, + $ZodIntersection, + $ZodLazy, + $ZodLiteral, + $ZodMap, + $ZodNonOptional, + $ZodNullable, + $ZodNumber, + $ZodNumberFormats, + $ZodObject, + $ZodOptional, + $ZodPipe, + $ZodReadonly, + $ZodRecord, + $ZodSet, + $ZodString, + $ZodStringFormats, + $ZodTemplateLiteral, + $ZodTuple, + $ZodType, + $ZodUnion, +} from '@zod/core' +import { JSONSchemaFormat } from '@orpc/openapi' +import { intercept } from '@orpc/shared' +import { + globalRegistry, +} from '@zod/core' +import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries' + +const formatMap: Partial> = { + guid: JSONSchemaFormat.UUID, + url: JSONSchemaFormat.URI, + datetime: JSONSchemaFormat.DateTime, + json_string: 'json-string', +} + +export interface ZodToJsonSchemaOptions { + /** + * Max depth of lazy type, if it exceeds. + * + * Used anyJsonSchema (`{}`) when reach max depth + * + * @default 2 + */ + maxLazyDepth?: number + + /** + * The schema to be used to represent the any | unknown type. + * + * @default { } + */ + anyJsonSchema?: Exclude + + /** + * The schema to be used when the Zod schema is unsupported. + * + * @default { not: {} } + */ + unsupportedJsonSchema?: Exclude + + /** + * The schema to be used to represent the undefined type. + * + * @default { not: {} } + */ + undefinedJsonSchema?: Exclude + + interceptors?: Interceptor< + { schema: $ZodType, options: SchemaConvertOptions, lazyDepth: number, isHandledCustomJSONSchema: boolean }, + [required: boolean, jsonSchema: Exclude], + ThrowableError + >[] +} + +export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { + private readonly maxLazyDepth: Exclude + private readonly anyJsonSchema: Exclude + private readonly unsupportedJsonSchema: Exclude + private readonly undefinedJsonSchema: Exclude + private readonly interceptors: Exclude + + constructor(options: ZodToJsonSchemaOptions = {}) { + this.maxLazyDepth = options.maxLazyDepth ?? 2 + this.anyJsonSchema = options.anyJsonSchema ?? {} + this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } + this.undefinedJsonSchema = options.undefinedJsonSchema ?? { not: {} } + this.interceptors = options.interceptors ?? [] + } + + condition(schema: AnySchema | undefined): boolean { + return schema !== undefined && schema['~standard'].vendor === 'zod' + } + + convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<[required: boolean, jsonSchema: JSONSchema]> { + return this.#convert(schema as $ZodType, options, 0) + } + + #convert( + schema: $ZodType, + options: SchemaConvertOptions, + lazyDepth: number, + isHandledCustomJSONSchema: boolean = false, + ): Promise<[required: boolean, jsonSchema: Exclude]> { + return intercept( + this.interceptors, + { schema, options, lazyDepth, isHandledCustomJSONSchema }, + async ({ schema, options, lazyDepth, isHandledCustomJSONSchema }) => { + if (!isHandledCustomJSONSchema) { + const customJSONSchema = this.#getCustomJsonSchema(schema, options) + + if (customJSONSchema) { + const [required, json] = await this.#convert(schema, options, lazyDepth, true) + + return [required, { ...json, ...customJSONSchema }] + } + } + + switch (schema._zod.def.type) { + case 'string': { + const string = schema as $ZodString + const json: JSONSchema = { type: 'string' } + + const { minimum, maximum, format, pattern, contentEncoding } = string._zod.computed as { + minimum?: number + maximum?: number + format?: $ZodStringFormats + pattern?: RegExp + contentEncoding?: string + } + + if (minimum !== undefined) { + json.minimum = minimum + } + + if (maximum !== undefined) { + json.maximum = maximum + } + + if (format !== undefined) { + json.format = formatMap[format] ?? format + } + + if (pattern !== undefined) { + json.pattern = pattern.source + } + + if (contentEncoding !== undefined) { + json.contentEncoding = contentEncoding as any + } + + return [true, json] + } + + case 'number': { + const number = schema as $ZodNumber + const json: JSONSchema = { type: 'number' } + + const { minimum, maximum, format, multipleOf, inclusive } = number._zod.computed as { + minimum?: number + maximum?: number + format?: $ZodNumberFormats + multipleOf?: number + inclusive?: boolean + } + + if (format?.includes('int')) { + json.type = 'integer' + } + + if (minimum !== undefined) { + if (inclusive) { + json.minimum = minimum + } + else { + json.exclusiveMinimum = minimum + } + } + + if (maximum !== undefined) { + if (inclusive) { + json.maximum = maximum + } + else { + json.exclusiveMaximum = maximum + } + } + + if (multipleOf !== undefined) { + json.multipleOf = multipleOf + } + + return [true, json] + } + + case 'boolean': { + return [true, { type: 'boolean' }] + } + + case 'bigint': { + return [true, { type: 'string', pattern: '^-?[0-9]+$' }] + } + + case 'date': { + return [true, { type: 'string', format: JSONSchemaFormat.DateTime }] + } + + case 'null': { + return [true, { type: 'null' }] + } + + case 'undefined': + case 'void': { + return [false, this.undefinedJsonSchema] + } + + case 'any': { + return [false, this.anyJsonSchema] + } + + case 'unknown': { + return [false, this.anyJsonSchema] + } + + case 'never': { + return [true, this.unsupportedJsonSchema] + } + + case 'array': { + const array = schema as $ZodArray + const json: JSONSchema = { type: 'array' } + + const { minimum, maximum } = array._zod.computed as { + minimum?: number + maximum?: number + } + + if (minimum !== undefined) { + json.minItems = minimum + } + + if (maximum !== undefined) { + json.maxItems = maximum + } + + json.items = this.#handleArrayItemJsonSchema(await this.#convert(array._zod.def.element, options, lazyDepth), options) + + return [true, json] + } + + case 'object': + case 'interface': { + const object = schema as $ZodObject | $ZodInterface + const json: JSONSchema & { required?: string[] } = { type: 'object' } + + for (const [key, value] of Object.entries(object._zod.def.shape)) { + const [itemRequired, itemJson] = await this.#convert(value, options, lazyDepth) + + json.properties ??= {} + json.properties[key] = itemJson + + if (itemRequired) { + json.required ??= [] + json.required.push(key) + } + } + + if (object._zod.def.catchall) { + if (object._zod.def.catchall._zod.def.type === 'never') { + json.additionalProperties = false + } + else { + const [_, addJson] = await this.#convert(object._zod.def.catchall, options, lazyDepth) + json.additionalProperties = addJson + } + } + + return [true, json] + } + + case 'union': { + const union = schema as $ZodUnion + const json: JSONSchema & { anyOf: Exclude[] } = { anyOf: [] } + + let required = true + + for (const item of union._zod.def.options) { + const [itemRequired, itemJson] = await this.#convert(item, options, lazyDepth) + + if (!itemRequired) { + required = false + } + + if (options.strategy === 'input') { + if (itemJson !== this.undefinedJsonSchema && itemJson !== this.unsupportedJsonSchema) { + json.anyOf.push(itemJson) + } + } + else { + if (itemJson !== this.undefinedJsonSchema) { + json.anyOf.push(itemJson) + } + } + } + + return [required, json] + } + + case 'intersection': { + const intersection = schema as $ZodIntersection + const json: JSONSchema & { allOf: Exclude[] } = { allOf: [] } + + let required = false + + for (const item of [intersection._zod.def.left, intersection._zod.def.right]) { + const [itemRequired, itemJson] = await this.#convert(item, options, lazyDepth) + + json.allOf.push(itemJson) + + if (itemRequired) { + required = true + } + } + + return [required, json] + } + + case 'tuple': { + const tuple = schema as $ZodTuple + const json: JSONSchema & { prefixItems: JSONSchema[] } = { type: 'array', prefixItems: [] } + + for (const item of tuple._zod.def.items) { + json.prefixItems.push(this.#handleArrayItemJsonSchema(await this.#convert(item, options, lazyDepth), options)) + } + + if (tuple._zod.def.rest) { + json.items = this.#handleArrayItemJsonSchema(await this.#convert(tuple._zod.def.rest, options, lazyDepth), options) + } + + const { minimum, maximum } = tuple._zod.computed as { + minimum?: number + maximum?: number + } + + if (minimum !== undefined) { + json.minItems = minimum + } + + if (maximum !== undefined) { + json.maxItems = maximum + } + + return [true, json] + } + + case 'record': { + const record = schema as $ZodRecord + const json: JSONSchema = { type: 'object' } + + json.propertyNames = (await this.#convert(record._zod.def.keyType, options, lazyDepth))[1] + json.additionalProperties = (await this.#convert(record._zod.def.valueType, options, lazyDepth))[1] + + return [true, json] + } + + case 'map': { + const map = schema as $ZodMap + + return [true, { + type: 'array', + items: { + type: 'array', + prefixItems: [ + this.#handleArrayItemJsonSchema(await this.#convert(map._zod.def.keyType, options, lazyDepth), options), + this.#handleArrayItemJsonSchema(await this.#convert(map._zod.def.valueType, options, lazyDepth), options), + ], + maxItems: 2, + minItems: 2, + }, + }] + } + + case 'set': { + const set = schema as $ZodSet + return [true, { + type: 'array', + uniqueItems: true, + items: this.#handleArrayItemJsonSchema(await this.#convert(set._zod.def.valueType, options, lazyDepth), options), + }] + } + + case 'enum': { + const enum_ = schema as $ZodEnum + return [true, { enum: Object.values(enum_._zod.def.entries) }] + } + + case 'literal': { + const literal = schema as $ZodLiteral + + let required = true + const values = new Set() + + for (const value of literal._zod.def.values) { + if (value === undefined) { + required = false + } + else { + values.add(typeof value === 'bigint' ? value.toString() : value) + } + } + + const json: JSONSchema = values.size === 0 + ? this.undefinedJsonSchema + : values.size === 1 + ? { const: values.values().next().value } + : { enum: Array.from(values) } + + return [required, json] + } + + case 'file': { + const file = schema as $ZodFile + const oneOf: Exclude[] = [] + + const { mime } = file._zod.computed as { + mime?: string[] + minimum?: number // WARN: ignore + maximum?: number // WARN: ignore + } + + for (const type of mime ?? ['*/*']) { + oneOf.push({ + type: 'string', + contentMediaType: type, + }) + } + + return [true, oneOf.length === 1 ? oneOf[0]! : { anyOf: oneOf }] + } + + case 'transform': { + return [false, this.anyJsonSchema] + } + + case 'nullable': { + const nullable = schema as $ZodNullable + + const [required, json] = await this.#convert(nullable._zod.def.innerType, options, lazyDepth) + + return [required, { anyOf: [json, { type: 'null' }] }] + } + + case 'nonoptional': { + const nonoptional = schema as $ZodNonOptional + const [, json] = await this.#convert(nonoptional._zod.def.innerType, options, lazyDepth) + return [true, json] + } + + case 'success': { + return [true, { type: 'boolean' }] + } + + case 'default': { + const default_ = schema as $ZodDefault + const [required, json] = await this.#convert(default_._zod.def.innerType, options, lazyDepth) + + return [required, { + ...json, + default: default_._zod.def.defaultValue(), + }] + } + + case 'catch': { + const catch_ = schema as $ZodCatch + const [required, json] = await this.#convert(catch_._zod.def.innerType, options, lazyDepth) + + let defaultValue: Exclude | undefined + + try { + defaultValue = { default: catch_._zod.def.catchValue(undefined as any) } + } + catch { + } + + return [required, { + ...json, + ...defaultValue, + }] + } + + case 'nan': { + return [true, options.strategy === 'input' ? this.unsupportedJsonSchema : { type: 'null' }] + } + + case 'pipe': { + const pipe = schema as $ZodPipe + return await this.#convert(options.strategy === 'input' ? pipe._zod.def.in : pipe._zod.def.out, options, lazyDepth) + } + + case 'readonly': { + const readonly_ = schema as $ZodReadonly + const [required, json] = await this.#convert(readonly_._zod.def.innerType, options, lazyDepth) + return [required, { ...json, readOnly: true }] + } + + case 'template_literal': { + const templateLiteral = schema as $ZodTemplateLiteral + + return [true, { + type: 'string', + pattern: templateLiteral._zod.pattern.source, + }] + } + + case 'optional': { + const optional = schema as $ZodOptional + const [, json] = await this.#convert(optional._zod.def.innerType, options, lazyDepth) + return [false, json] + } + + case 'lazy': { + const lazy = schema as $ZodLazy + + if (lazyDepth > this.maxLazyDepth) { + return [false, this.anyJsonSchema] + } + + return await this.#convert(lazy._zod.def.getter(), options, lazyDepth + 1) + } + + default: { + const _unsupported: 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type + return [true, this.unsupportedJsonSchema] + } + } + }, + ) + } + + #getCustomJsonSchema(schema: $ZodType, options: SchemaConvertOptions): Exclude | undefined { + if (options.strategy === 'input' && JSON_SCHEMA_INPUT_REGISTRY.has(schema)) { + return JSON_SCHEMA_INPUT_REGISTRY.get(schema) as Exclude | undefined + } + + if (options.strategy === 'output' && JSON_SCHEMA_OUTPUT_REGISTRY.has(schema)) { + return JSON_SCHEMA_OUTPUT_REGISTRY.get(schema) as Exclude | undefined + } + + if (JSON_SCHEMA_REGISTRY.has(schema)) { + return JSON_SCHEMA_REGISTRY.get(schema) as Exclude | undefined + } + + const global = globalRegistry.get(schema) + + if (global) { + return { + description: global.description, + examples: global.examples, + } + } + } + + #handleArrayItemJsonSchema([required, schema]: [required: boolean, jsonSchema: Exclude], options: SchemaConvertOptions): Exclude { + if (required || options.strategy === 'input') { + return schema + } + + if (schema === this.undefinedJsonSchema) { + return { type: 'null' } + } + + return { + anyOf: [ // schema can contain { type: 'null' } so we should use anyOf instead of oneOf + schema, + { type: 'null' }, + ], + } + } +} diff --git a/packages/zod/src/v4/index.ts b/packages/zod/src/v4/index.ts new file mode 100644 index 000000000..d9e4c2818 --- /dev/null +++ b/packages/zod/src/v4/index.ts @@ -0,0 +1,2 @@ +export * from './converter' +export * from './registries' diff --git a/packages/zod/src/v4/registries.ts b/packages/zod/src/v4/registries.ts new file mode 100644 index 000000000..3deb11849 --- /dev/null +++ b/packages/zod/src/v4/registries.ts @@ -0,0 +1,60 @@ +import type { JSONSchema } from '@orpc/openapi' +import type { $input, $output } from '@zod/core' +import { registry } from '@zod/core' + +/** + * Zod registry for customizing generated JSON schema, can use both for .input and .output + * + * @example + * ```ts + * import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/v4' + * + * const user = z.object({ + * name: z.string(), + * age: z.number(), + * }) + * + * JSON_SCHEMA_REGISTRY.add(user, { + * examples: [{ name: 'John', age: 20 }], + * }) + * ``` + */ +export const JSON_SCHEMA_REGISTRY = registry, boolean>>() + +/** + * Zod registry for customizing generated JSON schema, only useful for .input + * + * @example + * ```ts + * import { JSON_SCHEMA_INPUT_REGISTRY } from '@orpc/zod/v4' + * + * const user = z.object({ + * name: z.string(), + * age: z.string().transform(v => Number(v)), + * }) + * + * JSON_SCHEMA_REGISTRY.add(user, { + * examples: [{ name: 'John', age: "20" }], + * }) + * ``` + */ +export const JSON_SCHEMA_INPUT_REGISTRY = registry, boolean>>() + +/** + * Zod registry for customizing generated JSON schema, only useful for .input + * + * @example + * ```ts + * import { JSON_SCHEMA_OUTPUT_REGISTRY } from '@orpc/zod/v4' + * + * const user = z.object({ + * name: z.string(), + * age: z.string().transform(v => Number(v)), + * }) + * + * JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { + * examples: [{ name: 'John', age: 20 }], + * }) + * ``` + */ +export const JSON_SCHEMA_OUTPUT_REGISTRY = registry, boolean>>() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625ec3d9c..195f1158d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,13 +506,22 @@ importers: wildcard-match: specifier: ^5.1.3 version: 5.1.4 + devDependencies: + '@zod/core': + specifier: ^0.10.0 + version: 0.10.0 + '@zod/mini': + specifier: ^@zod/mini@4.0.0-beta.20250503T014749 + version: link:^@zod/mini@4.0.0-beta.20250503T014749 zod: specifier: ^3.24.2 version: 3.24.2 - devDependencies: zod-to-json-schema: specifier: ^3.24.5 version: 3.24.5(zod@3.24.2) + zod4: + specifier: npm:zod@^4.0.0-beta.20250503T014749 + version: zod@4.0.0-beta.20250503T014749 playgrounds/contract-first: devDependencies: @@ -3139,6 +3148,12 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@zod/core@0.10.0': + resolution: {integrity: sha512-iMITRygme3v9jPsITJjvRMw60+MQq7MWnNpJleRkfjeSCjBm3c1/tiw3NUS4re/M2CBXVP5kAjI7sQrf22twXA==} + + '@zod/core@0.10.1': + resolution: {integrity: sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7284,6 +7299,9 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@4.0.0-beta.20250503T014749: + resolution: {integrity: sha512-ND9JjNpf2IaTZlHr4xgvWbOmzOwjDzrlCqBlhpnYSpXcx6DFzmLJrWhCZc4xgNGieD7MCx/ZoWIHDGZzmg/gnA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9841,6 +9859,10 @@ snapshots: transitivePeerDependencies: - typescript + '@zod/core@0.10.0': {} + + '@zod/core@0.10.1': {} + abbrev@2.0.0: {} abbrev@3.0.1: {} @@ -14732,4 +14754,8 @@ snapshots: zod@3.24.2: {} + zod@4.0.0-beta.20250503T014749: + dependencies: + '@zod/core': 0.10.1 + zwitch@2.0.4: {} From 5569753a290638f636a822cdc29984a0f118bfcc Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 3 May 2025 21:19:59 +0700 Subject: [PATCH 02/13] wip --- packages/openapi/src/schema.ts | 4 +- packages/zod/src/v4/converter.test.ts | 571 ++++++++++++++++++++++++++ packages/zod/src/v4/converter.ts | 63 ++- 3 files changed, 620 insertions(+), 18 deletions(-) create mode 100644 packages/zod/src/v4/converter.test.ts diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts index 7e7a36d85..153f0c8f9 100644 --- a/packages/openapi/src/schema.ts +++ b/packages/openapi/src/schema.ts @@ -1,8 +1,8 @@ /* eslint-disable no-restricted-imports */ import type { JSONSchema, keywords } from 'json-schema-typed/draft-2020-12' -import { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' +import { ContentEncoding as JSONSchemaContentEncoding, Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' -export { JSONSchemaFormat } +export { JSONSchemaContentEncoding, JSONSchemaFormat } export type { JSONSchema } /** diff --git a/packages/zod/src/v4/converter.test.ts b/packages/zod/src/v4/converter.test.ts new file mode 100644 index 000000000..eca880779 --- /dev/null +++ b/packages/zod/src/v4/converter.test.ts @@ -0,0 +1,571 @@ +import type { JSONSchema } from '@orpc/openapi' +import type { $ZodType } from '@zod/core' +import * as z from 'zod4' +import { ZodToJsonSchemaConverter } from './converter' + +type SchemaTestCase = { + schema: $ZodType + input: [boolean, Exclude] + output?: [boolean, Exclude] +} + +const stringCases: SchemaTestCase[] = [ + { + schema: z.string(), + input: [true, { type: 'string' }], + }, + { + schema: z.string().min(5).max(10).regex(/^[a-z\\]+$/), + input: [true, { type: 'string', maxLength: 10, minLength: 5, pattern: '^[a-z\\\\]+$' }], + }, + { + schema: z.base64(), + input: [true, { type: 'string', contentEncoding: 'base64' }], + }, + { + schema: z.cuid(), + input: [true, { type: 'string', pattern: '^[cC][^\\s-]{8,}$' }], + }, + { + schema: z.email(), + input: [true, { type: 'string', format: 'email' }], + }, + { + schema: z.url(), + input: [true, { type: 'string', format: 'uri' }], + }, + { + schema: z.uuid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + schema: z.string().length(6), + input: [true, { type: 'string', minLength: 6, maxLength: 6 }], + }, + { + schema: z.string().includes('a\\'), + input: [true, { type: 'string', pattern: 'a\\\\' }], + }, + { + schema: z.string().startsWith('a\\'), + input: [true, { type: 'string', pattern: '^a\\\\.*' }], + }, + { + schema: z.string().endsWith('a\\'), + input: [true, { type: 'string', pattern: '.*a\\\\$' }], + }, + { + schema: z.emoji(), + input: [true, { type: 'string', pattern: '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' }], + }, + { + schema: z.nanoid(), + input: [true, { type: 'string', pattern: '^[a-zA-Z0-9_-]{21}$' }], + }, + { + schema: z.cuid2(), + input: [true, { type: 'string', pattern: '^[0-9a-z]+$' }], + }, + { + schema: z.ulid(), + input: [true, { + type: 'string', + pattern: '^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$', + }], + }, + { + schema: z.iso.datetime(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + schema: z.iso.date(), + input: [true, { type: 'string', format: 'date' }], + }, + { + schema: z.iso.time(), + input: [true, { type: 'string', format: 'time' }], + }, + { + schema: z.iso.duration(), + input: [true, { type: 'string', format: 'duration' }], + }, + { + schema: z.ipv4(), + input: [true, { type: 'string', format: 'ipv4' }], + }, + { + schema: z.ipv6(), + input: [true, { + type: 'string', + format: 'ipv6', + }], + }, + { + schema: z.jwt(), + input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]*$' }], + }, + { + schema: z.base64url(), + input: [true, { type: 'string', pattern: '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' }], + }, + { + schema: z.string().trim(), + input: [true, { type: 'string' }], + }, +] + +const numberCases: SchemaTestCase[] = [ + { + schema: z.number(), + input: [true, { type: 'number' }], + }, + { + schema: z.number().int(), + input: [true, { type: 'integer' }], + }, + { + schema: z.number().min(0).max(100).int(), + input: [true, { type: 'integer', minimum: 0, maximum: 100 }], + }, + { + schema: z.number().multipleOf(5), + input: [true, { type: 'number', multipleOf: 5 }], + }, + { + schema: z.number().finite(), + input: [true, { type: 'number' }], + }, + { + schema: z.bigint(), + input: [true, { type: 'string', pattern: '^-?[0-9]+$' }], + }, + { + schema: z.nan(), + input: [true, { not: {} }], + output: [true, { type: 'null' }], + }, +] + +enum ExampleEnum { + A = 'a', + B = 'b', +} + +const nativeCases: SchemaTestCase[] = [ + { + schema: z.boolean(), + input: [true, { type: 'boolean' }], + }, + { + schema: z.date(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + schema: z.null(), + input: [true, { type: 'null' }], + }, + { + schema: z.any(), + input: [false, { }], + }, + { + schema: z.unknown(), + input: [false, {}], + }, + { + schema: z.undefined(), + input: [false, { not: {} }], + }, + { + schema: z.void(), + input: [false, { not: {} }], + }, + { + schema: z.literal(1234), + input: [true, { const: 1234 }], + }, + { + schema: z.literal(undefined), + input: [false, { not: {} }], + }, + { + schema: z.enum(['a', 'b']), + input: [true, { enum: ['a', 'b'] }], + }, + { + schema: z.nativeEnum(ExampleEnum), + input: [true, { enum: ['a', 'b'] }], + }, +] + +const combinationCases: SchemaTestCase[] = [ + { + schema: z.union([z.string(), z.number()]), + input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + schema: z.union([z.string(), z.number().optional()]), + input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + schema: z.union([z.string(), z.undefined()]), + input: [false, { type: 'string' }], + }, + { + schema: z.intersection(z.string(), z.number()), + input: [true, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + schema: z.intersection(z.string().optional(), z.number().optional()), + input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, +] + +const processedCases: SchemaTestCase[] = [ + { + schema: z.lazy(() => z.object({ value: z.string() })), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + }, + { + schema: z.lazy(() => z.object({ value: z.lazy(() => z.string()) })), + input: [true, { type: 'object', properties: { value: { } } }], + }, + { + schema: z.string().transform(x => x), + input: [true, { type: 'string' }], + output: [false, {}], + }, + { + schema: z.string().refine(x => x.length > 0, 'not empty'), + input: [true, { type: 'string' }], + }, + { + schema: z.preprocess(x => x, z.string()), + input: [true, { type: 'string' }], + }, + { + schema: z.number().catch(1), + input: [true, { type: 'number' }], + }, + { + schema: z.number().brand<'CAT'>(), + input: [true, { type: 'number' }], + }, + { + schema: z.number().brand<'CAT'>(), + input: [true, { type: 'number' }], + }, + // { + // schema: z.pipeline(z.number(), z.string()), + // input: [true, { type: 'number' }], + // output: [true, { type: 'string' }], + // }, + { + schema: z.string().nullable(), + input: [true, { anyOf: [{ type: 'null' }, { type: 'string' }] }], + }, + { + schema: z.string().default('a'), + input: [false, { default: 'a', type: 'string' }], + }, + { + schema: z.number().readonly(), + input: [true, { type: 'number', readOnly: true }], + }, +] + +const unsupportedCases: SchemaTestCase[] = [ + { + schema: z.promise(z.string()), + input: [true, { not: {} }], + }, + { + schema: z.symbol(), + input: [true, { not: {} }], + }, + // { + // schema: z.function(), + // input: [true, { not: {} }], + // ignoreZodToJsonSchema: true, + // }, + { + schema: z.never(), + input: [true, { not: {} }], + }, +] + +// const extendSchemaCases: SchemaTestCase[] = [ +// { +// schema: file(), +// input: [true, { type: 'string', contentMediaType: '*/*' }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: file().type('image/png'), +// input: [true, { type: 'string', contentMediaType: 'image/png' }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: blob(), +// input: [true, { type: 'string', contentMediaType: '*/*' }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: regexp(), +// input: [true, { type: 'string', pattern: '^\\/(.*)\\/([a-z]*)$' }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: url(), +// input: [true, { type: 'string', format: 'uri' }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: customJsonSchema(z.string(), { examples: ['a', 'b'] }), +// input: [true, { type: 'string', examples: ['a', 'b'] }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: customJsonSchema( +// customJsonSchema( +// customJsonSchema(z.string(), { examples: ['both'] }), +// { examples: ['input'] }, +// { strategy: 'input' }, +// ), +// { examples: ['output'] }, +// { strategy: 'output' }, +// ), +// input: [true, { type: 'string', examples: ['input'] }], +// output: [true, { type: 'string', examples: ['output'] }], +// ignoreZodToJsonSchema: true, +// }, +// { +// schema: z.string().describe('description'), +// input: [true, { type: 'string', description: 'description' }], +// ignoreZodToJsonSchema: true, +// }, +// ] + +const edgeCases: SchemaTestCase[] = [ + { + schema: z.array(z.string()).nonempty(), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 1 }], + }, + { + schema: z.array(z.string()).min(10).max(20), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 20 }], + }, + { + schema: z.array(z.string()).length(10), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 10 }], + }, + { + schema: z.object({ value: z.string() }).strict(), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: false }], + }, + { + schema: z.object({ value: z.string() }).catchall(z.number()), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: { type: 'number' } }], + }, + { + schema: z.record(z.number(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'number' } }], + }, + { + schema: z.record(z.string().date(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'string', format: 'date' } }], + }, +] + +describe.each([ + ...stringCases, + // ...numberCases, + // ...nativeCases, + // ...combinationCases, + // ...processedCases, + // ...extendSchemaCases, + // ...unsupportedCases, + // ...edgeCases, +])('zodToJsonSchemaConverter.convert %#', ({ schema, input, output = input }) => { + describe.each([['input'], ['output']] as const)('strategy: %s', (strategy) => { + const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) + + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const arrayItemJsonSchema = expectedRequired + ? expectedJson + : strategy === 'input' + ? { anyOf: [expectedJson, { not: {} }] } + : { anyOf: [expectedJson, { type: 'null' }] } + + it('flat', async () => { + const [required, json] = await converter.convert(schema, { strategy }) + + expect(required).toEqual(expectedRequired) + expect(json).toEqual(expectedJson) + }) + + // it('object', async () => { + // const testSchema = z.object({ value: schema }) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'object', + // properties: { + // value: expectedJson, + // }, + // required: expectedRequired ? ['value'] : undefined, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect(json).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // additionalProperties: undefined, + // }) + // } + // }) + + // it('array', async () => { + // const testSchema = z.array(schema) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'array', + // items: arrayItemJsonSchema, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect(json).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // }) + // } + // }) + + // it('tuple', async () => { + // const testSchema = z.tuple([schema, schema]).rest(schema) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'array', + // prefixItems: [ + // arrayItemJsonSchema, + // arrayItemJsonSchema, + // ], + // items: arrayItemJsonSchema, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect({ + // type: 'array', + // items: [ + // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // ], + // additionalItems: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // }).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // maxItems: undefined, + // minItems: undefined, + // }) + // } + // }) + + // it('set', async () => { + // const testSchema = z.set(schema) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'array', + // uniqueItems: true, + // items: arrayItemJsonSchema, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect({ + // type: 'array', + // uniqueItems: true, + // items: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // }).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // }) + // } + // }) + + // it('map', async () => { + // const testSchema = z.map(schema, schema.optional()) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'array', + // items: { + // type: 'array', + // maxItems: 2, + // minItems: 2, + // prefixItems: [ + // arrayItemJsonSchema, + // { anyOf: [expectedJson, strategy === 'input' ? { not: {} } : { type: 'null' }] }, + // ], + // }, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect({ + // type: 'array', + // items: { + // maxItems: 2, + // minItems: 2, + // items: [ + // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // { anyOf: [{ not: {} }, expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }] }, + // ], + // type: 'array', + // }, + // }).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // maxItems: undefined, + // }) + // } + // }) + + // it('record', async () => { + // const testSchema = z.record(schema) + // const [required, json] = await converter.convert(testSchema, { strategy }) + + // expect(required).toEqual(true) + // expect(json).toEqual({ + // type: 'object', + // additionalProperties: expectedJson, + // }) + + // if (!ignoreZodToJsonSchema) { + // expect({ + // type: 'object', + // additionalProperties: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + // }).toEqual({ + // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), + // $schema: undefined, + // maxItems: undefined, + // }) + // } + // }) + }) +}) + +it('zodToJsonSchemaConverter.condition', async () => { + const converter = new ZodToJsonSchemaConverter() + expect(converter.condition(z.string())).toBe(true) + expect(converter.condition(z.string().optional())).toBe(true) + + const v = await import('valibot') + + expect(converter.condition(v.string())).toBe(false) +}) diff --git a/packages/zod/src/v4/converter.ts b/packages/zod/src/v4/converter.ts index 42607c954..ce22fbacd 100644 --- a/packages/zod/src/v4/converter.ts +++ b/packages/zod/src/v4/converter.ts @@ -29,20 +29,13 @@ import type { $ZodType, $ZodUnion, } from '@zod/core' -import { JSONSchemaFormat } from '@orpc/openapi' +import { JSONSchemaContentEncoding, JSONSchemaFormat } from '@orpc/openapi' import { intercept } from '@orpc/shared' import { globalRegistry, } from '@zod/core' import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries' -const formatMap: Partial> = { - guid: JSONSchemaFormat.UUID, - url: JSONSchemaFormat.URI, - datetime: JSONSchemaFormat.DateTime, - json_string: 'json-string', -} - export interface ZodToJsonSchemaOptions { /** * Max depth of lazy type, if it exceeds. @@ -100,7 +93,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { return schema !== undefined && schema['~standard'].vendor === 'zod' } - convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<[required: boolean, jsonSchema: JSONSchema]> { + convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<[required: boolean, jsonSchema: Exclude]> { return this.#convert(schema as $ZodType, options, 0) } @@ -138,23 +131,33 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } if (minimum !== undefined) { - json.minimum = minimum + json.minLength = minimum } if (maximum !== undefined) { - json.maximum = maximum + json.maxLength = maximum + } + + if (contentEncoding !== undefined) { + json.contentEncoding = this.#handleContentEncoding(contentEncoding) } - if (format !== undefined) { - json.format = formatMap[format] ?? format + /** + * JSON Schema's "regex" format means the string _is_ a regex pattern. + * Zod’s regex expects the string _to match_ a pattern. + * These differ, so we ignore the "regex" format here. + */ + if (format !== undefined && format !== 'regex' && json.contentEncoding === undefined) { + json.format = this.#handleStringFormat(format) } - if (pattern !== undefined) { + if (pattern !== undefined && json.contentEncoding === undefined && json.format === undefined) { json.pattern = pattern.source } - if (contentEncoding !== undefined) { - json.contentEncoding = contentEncoding as any + // Add a pattern for JWT if it's missing (acts as a polyfill for Zod v4) + if (format === 'jwt' && json.contentEncoding && json.format === undefined && json.pattern === undefined) { + json.pattern = /^[\w-]+\.[\w-]+\.[\w-]*$/.source } return [true, json] @@ -584,4 +587,32 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { ], } } + + #handleStringFormat(format: string): string | undefined { + if (format === 'guid') { + return JSONSchemaFormat.UUID + } + + if (format === 'url') { + return JSONSchemaFormat.URI + } + + if (format === 'datetime') { + return JSONSchemaFormat.DateTime + } + + if (format === 'json_string') { + return 'json-string' + } + + return Object.values(JSONSchemaFormat).includes(format as any) + ? format + : undefined + } + + #handleContentEncoding(contentEncoding: string): Exclude['contentEncoding'] | undefined { + return Object.values(JSONSchemaContentEncoding).includes(contentEncoding as any) + ? contentEncoding as any + : undefined + } } From 32fad04fa7caaacedeb5b1c9356c96e3d49c02f5 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 3 May 2025 21:52:19 +0700 Subject: [PATCH 03/13] wip --- packages/zod/src/v4/converter.test.ts | 228 +++++++++----------------- packages/zod/src/v4/converter.ts | 4 +- 2 files changed, 80 insertions(+), 152 deletions(-) diff --git a/packages/zod/src/v4/converter.test.ts b/packages/zod/src/v4/converter.test.ts index eca880779..c4385e18d 100644 --- a/packages/zod/src/v4/converter.test.ts +++ b/packages/zod/src/v4/converter.test.ts @@ -1,10 +1,9 @@ import type { JSONSchema } from '@orpc/openapi' -import type { $ZodType } from '@zod/core' import * as z from 'zod4' import { ZodToJsonSchemaConverter } from './converter' type SchemaTestCase = { - schema: $ZodType + schema: z.ZodType input: [boolean, Exclude] output?: [boolean, Exclude] } @@ -102,7 +101,7 @@ const stringCases: SchemaTestCase[] = [ }, { schema: z.jwt(), - input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]*$' }], + input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]+$' }], }, { schema: z.base64url(), @@ -132,7 +131,7 @@ const numberCases: SchemaTestCase[] = [ input: [true, { type: 'number', multipleOf: 5 }], }, { - schema: z.number().finite(), + schema: z.number(), input: [true, { type: 'number' }], }, { @@ -379,7 +378,7 @@ const edgeCases: SchemaTestCase[] = [ describe.each([ ...stringCases, - // ...numberCases, + ...numberCases, // ...nativeCases, // ...combinationCases, // ...processedCases, @@ -404,159 +403,88 @@ describe.each([ expect(json).toEqual(expectedJson) }) - // it('object', async () => { - // const testSchema = z.object({ value: schema }) - // const [required, json] = await converter.convert(testSchema, { strategy }) - - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'object', - // properties: { - // value: expectedJson, - // }, - // required: expectedRequired ? ['value'] : undefined, - // }) - - // if (!ignoreZodToJsonSchema) { - // expect(json).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // additionalProperties: undefined, - // }) - // } - // }) - - // it('array', async () => { - // const testSchema = z.array(schema) - // const [required, json] = await converter.convert(testSchema, { strategy }) - - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'array', - // items: arrayItemJsonSchema, - // }) - - // if (!ignoreZodToJsonSchema) { - // expect(json).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // }) - // } - // }) - - // it('tuple', async () => { - // const testSchema = z.tuple([schema, schema]).rest(schema) - // const [required, json] = await converter.convert(testSchema, { strategy }) - - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'array', - // prefixItems: [ - // arrayItemJsonSchema, - // arrayItemJsonSchema, - // ], - // items: arrayItemJsonSchema, - // }) - - // if (!ignoreZodToJsonSchema) { - // expect({ - // type: 'array', - // items: [ - // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // ], - // additionalItems: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // }).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // maxItems: undefined, - // minItems: undefined, - // }) - // } - // }) - - // it('set', async () => { - // const testSchema = z.set(schema) - // const [required, json] = await converter.convert(testSchema, { strategy }) + it('object', async () => { + const testSchema = z.object({ value: schema }) + const [required, json] = await converter.convert(testSchema, { strategy }) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + properties: { + value: expectedJson, + }, + required: expectedRequired ? ['value'] : undefined, + }) + }) - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'array', - // uniqueItems: true, - // items: arrayItemJsonSchema, - // }) + it('array', async () => { + const testSchema = z.array(schema) + const [required, json] = await converter.convert(testSchema, { strategy }) - // if (!ignoreZodToJsonSchema) { - // expect({ - // type: 'array', - // uniqueItems: true, - // items: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // }).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // }) - // } - // }) + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + items: arrayItemJsonSchema, + }) + }) - // it('map', async () => { - // const testSchema = z.map(schema, schema.optional()) - // const [required, json] = await converter.convert(testSchema, { strategy }) + it('tuple', async () => { + const testSchema = z.tuple([schema, schema]).rest(schema) + const [required, json] = await converter.convert(testSchema, { strategy }) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + prefixItems: [ + arrayItemJsonSchema, + arrayItemJsonSchema, + ], + items: arrayItemJsonSchema, + }) + }) - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'array', - // items: { - // type: 'array', - // maxItems: 2, - // minItems: 2, - // prefixItems: [ - // arrayItemJsonSchema, - // { anyOf: [expectedJson, strategy === 'input' ? { not: {} } : { type: 'null' }] }, - // ], - // }, - // }) + it('set', async () => { + const testSchema = z.set(schema) + const [required, json] = await converter.convert(testSchema, { strategy }) - // if (!ignoreZodToJsonSchema) { - // expect({ - // type: 'array', - // items: { - // maxItems: 2, - // minItems: 2, - // items: [ - // expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // { anyOf: [{ not: {} }, expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }] }, - // ], - // type: 'array', - // }, - // }).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // maxItems: undefined, - // }) - // } - // }) + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + uniqueItems: true, + items: arrayItemJsonSchema, + }) + }) - // it('record', async () => { - // const testSchema = z.record(schema) - // const [required, json] = await converter.convert(testSchema, { strategy }) + it('map', async () => { + const testSchema = z.map(schema, schema.optional()) + const [required, json] = await converter.convert(testSchema, { strategy }) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + items: { + type: 'array', + maxItems: 2, + minItems: 2, + prefixItems: [ + arrayItemJsonSchema, + strategy === 'input' ? expectedJson : { anyOf: [expectedJson, { type: 'null' }] }, + ], + }, + }) + }) - // expect(required).toEqual(true) - // expect(json).toEqual({ - // type: 'object', - // additionalProperties: expectedJson, - // }) + it('record', async () => { + const testSchema = z.record(z.string().regex(/^\d+$/), schema) + const [required, json] = await converter.convert(testSchema, { strategy }) - // if (!ignoreZodToJsonSchema) { - // expect({ - // type: 'object', - // additionalProperties: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, - // }).toEqual({ - // ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), - // $schema: undefined, - // maxItems: undefined, - // }) - // } - // }) + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + additionalProperties: expectedJson, + propertyNames: { type: 'string', pattern: '^\\d+$' }, + }) + }) }) }) diff --git a/packages/zod/src/v4/converter.ts b/packages/zod/src/v4/converter.ts index ce22fbacd..70d2ce1a2 100644 --- a/packages/zod/src/v4/converter.ts +++ b/packages/zod/src/v4/converter.ts @@ -156,8 +156,8 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } // Add a pattern for JWT if it's missing (acts as a polyfill for Zod v4) - if (format === 'jwt' && json.contentEncoding && json.format === undefined && json.pattern === undefined) { - json.pattern = /^[\w-]+\.[\w-]+\.[\w-]*$/.source + if (format === 'jwt' && json.contentEncoding === undefined && json.format === undefined && json.pattern === undefined) { + json.pattern = /^[\w-]+\.[\w-]+\.[\w-]+$/.source } return [true, json] From 37a5bc1a886f9fc312570d3cbb6af5434030e1eb Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 4 May 2025 11:08:31 +0700 Subject: [PATCH 04/13] wip --- packages/zod/package.json | 4 +- .../zod/src/v4/converter.combination.test.ts | 35 ++ packages/zod/src/v4/converter.meta.test.ts | 73 +++ packages/zod/src/v4/converter.native.test.ts | 120 +++++ packages/zod/src/v4/converter.number.test.ts | 46 ++ .../zod/src/v4/converter.processed.test.ts | 21 + packages/zod/src/v4/converter.rest.test.ts | 20 + packages/zod/src/v4/converter.string.test.ts | 146 +++++ .../zod/src/v4/converter.structure.test.ts | 115 ++++ packages/zod/src/v4/converter.test.ts | 509 +----------------- packages/zod/src/v4/converter.ts | 34 +- packages/zod/tests/shared.ts | 22 + pnpm-lock.yaml | 9 +- 13 files changed, 636 insertions(+), 518 deletions(-) create mode 100644 packages/zod/src/v4/converter.combination.test.ts create mode 100644 packages/zod/src/v4/converter.meta.test.ts create mode 100644 packages/zod/src/v4/converter.native.test.ts create mode 100644 packages/zod/src/v4/converter.number.test.ts create mode 100644 packages/zod/src/v4/converter.processed.test.ts create mode 100644 packages/zod/src/v4/converter.rest.test.ts create mode 100644 packages/zod/src/v4/converter.string.test.ts create mode 100644 packages/zod/src/v4/converter.structure.test.ts create mode 100644 packages/zod/tests/shared.ts diff --git a/packages/zod/package.json b/packages/zod/package.json index 8d1a2eff5..f4e2cfc14 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -42,7 +42,7 @@ "peerDependencies": { "@orpc/contract": "workspace:*", "@orpc/server": "workspace:*", - "@zod/core": ">=0.10.0", + "@zod/core": ">=0.10.1", "zod": ">=3.24.2" }, "peerDependenciesMeta": { @@ -60,7 +60,7 @@ "wildcard-match": "^5.1.3" }, "devDependencies": { - "@zod/core": "^0.10.0", + "@zod/core": "^0.10.1", "@zod/mini": "^@zod/mini@4.0.0-beta.20250503T014749", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", diff --git a/packages/zod/src/v4/converter.combination.test.ts b/packages/zod/src/v4/converter.combination.test.ts new file mode 100644 index 000000000..dc8907426 --- /dev/null +++ b/packages/zod/src/v4/converter.combination.test.ts @@ -0,0 +1,35 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'union([z.string(), z.number()])', + schema: z.union([z.string(), z.number()]), + input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'union([z.string(), z.number().optional()])', + schema: z.union([z.string(), z.number().optional()]), + input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'union([z.string(), z.undefined()])', + schema: z.union([z.string(), z.undefined()]), + input: [false, { type: 'string' }], + }, + { + name: 'intersection(z.string(), z.number())', + schema: z.intersection(z.string(), z.number()), + input: [true, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'intersection(z.string().optional(), z.number().optional())', + schema: z.intersection(z.string().optional(), z.number().optional()), + input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, + { + name: 'intersection(z.string().optional(), z.number().optional())', + schema: z.intersection(z.string().optional(), z.number().optional()), + input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], + }, +]) diff --git a/packages/zod/src/v4/converter.meta.test.ts b/packages/zod/src/v4/converter.meta.test.ts new file mode 100644 index 000000000..4ec090e3a --- /dev/null +++ b/packages/zod/src/v4/converter.meta.test.ts @@ -0,0 +1,73 @@ +import * as z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' +import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries' + +const customSchema1 = z.string().meta({ + description: 'description', + examples: ['a', 'b'], +}) + +const customSchema2 = z.object({ + value: z.string(), +}) + +JSON_SCHEMA_REGISTRY.add(customSchema2, { + examples: [{ value: 'a' }], + description: 'objectSchema', +}) + +const customSchema3 = z.number().transform(v => v.toString()).pipe(z.string()) + +JSON_SCHEMA_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_REGISTRY', +}) + +JSON_SCHEMA_INPUT_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_INPUT_REGISTRY', + examples: [1], +}) + +JSON_SCHEMA_OUTPUT_REGISTRY.add(customSchema3, { + description: 'JSON_SCHEMA_OUTPUT_REGISTRY', + examples: ['1'], +}) + +testSchemaConverter([ + { + name: 'customSchema1', + schema: customSchema1, + input: [true, { type: 'string', description: 'description', examples: ['a', 'b'] }], + }, + { + name: 'customSchema2', + schema: customSchema2, + input: [true, { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + description: 'objectSchema', + examples: [{ value: 'a' }], + }], + }, + { + name: 'customSchema3', + schema: customSchema3, + input: [true, { type: 'number', description: 'JSON_SCHEMA_INPUT_REGISTRY', examples: [1] }], + output: [true, { type: 'string', description: 'JSON_SCHEMA_OUTPUT_REGISTRY', examples: ['1'] }], + }, + { + name: 'string.default("a")', + schema: z.string().default('a'), + input: [false, { default: 'a', type: 'string' }], + }, + { + name: 'string.catch("a")', + schema: z.string().catch('a'), + input: [false, { type: 'string' }], + }, + { + name: 'string.readonly()', + schema: z.string().readonly(), + input: [true, { type: 'string', readOnly: true }], + }, +]) diff --git a/packages/zod/src/v4/converter.native.test.ts b/packages/zod/src/v4/converter.native.test.ts new file mode 100644 index 000000000..9d10d18aa --- /dev/null +++ b/packages/zod/src/v4/converter.native.test.ts @@ -0,0 +1,120 @@ +import * as z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +enum ExampleEnum { + A = 'a', + B = 'b', +} + +testSchemaConverter([ + { + name: 'boolean', + schema: z.boolean(), + input: [true, { type: 'boolean' }], + }, + { + name: 'success(z.boolean())', + schema: z.success(z.boolean()), + input: [true, { type: 'boolean' }], + }, + { + name: 'date', + schema: z.date(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + name: 'null', + schema: z.null(), + input: [true, { type: 'null' }], + }, + { + name: 'any', + schema: z.any(), + input: [false, { }], + }, + { + name: 'unknown', + schema: z.unknown(), + input: [false, {}], + }, + { + name: 'undefined', + schema: z.undefined(), + input: [false, { not: {} }], + }, + { + name: 'string.optional()', + schema: z.string().optional(), + input: [false, { type: 'string' }], + }, + { + name: 'string.optional()', + schema: z.string().optional().nonoptional(), + input: [true, { type: 'string' }], + }, + { + name: 'string.nullable()', + schema: z.string().nullable(), + input: [true, { anyOf: [{ type: 'string' }, { type: 'null' }] }], + }, + { + name: 'void', + schema: z.void(), + input: [false, { not: {} }], + }, + { + name: 'never', + schema: z.never(), + input: [true, { not: {} }], + }, + { + name: 'literal(undefined)', + schema: z.literal(undefined), + input: [false, { not: {} }], + }, + { + name: 'literal(1234)', + schema: z.literal(1234), + input: [true, { const: 1234 }], + }, + { + name: 'literal([1234, 1234n, "abc"])', + schema: z.literal([1234, 1234n, 'abc']), + input: [true, { enum: [1234, '1234', 'abc'] }], + }, + { + name: 'literal([undefined, 1234, 1234n, "abc"])', + schema: z.literal([undefined, 1234, 1234n, 'abc']), + input: [false, { enum: [1234, '1234', 'abc'] }], + }, + { + name: 'enum(["a", "b"])', + schema: z.enum(['a', 'b']), + input: [true, { enum: ['a', 'b'] }], + }, + { + name: 'enum(ExampleEnum)', + schema: z.enum(ExampleEnum), + input: [true, { enum: ['a', 'b'] }], + }, + { + name: 'file().mime(["*/*"])', + schema: z.file(), + input: [true, { type: 'string', contentMediaType: '*/*' }], + }, + { + name: 'file().mime(["image/png"])', + schema: z.file().mime(['image/png']), + input: [true, { type: 'string', contentMediaType: 'image/png' }], + }, + { + name: 'file().mime(["image/png", "image/jpeg"])', + schema: z.file().mime(['image/png', 'image/jpeg']), + input: [true, { + anyOf: [ + { type: 'string', contentMediaType: 'image/png' }, + { type: 'string', contentMediaType: 'image/jpeg' }, + ], + }], + }, +]) diff --git a/packages/zod/src/v4/converter.number.test.ts b/packages/zod/src/v4/converter.number.test.ts new file mode 100644 index 000000000..13bca0758 --- /dev/null +++ b/packages/zod/src/v4/converter.number.test.ts @@ -0,0 +1,46 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'number', + schema: z.number(), + input: [true, { type: 'number' }], + }, + { + name: 'number.int()', + schema: z.number().int(), + input: [true, { type: 'integer', maximum: 9007199254740991, minimum: -9007199254740991 }], + }, + { + name: 'int.min(0).max(100)', + schema: z.int().min(0).max(100), + input: [true, { type: 'integer', maximum: 100, minimum: 0 }], + }, + { + name: 'number.min(0).max(100)', + schema: z.number().min(0).max(100), + input: [true, { type: 'number', minimum: 0, maximum: 100 }], + }, + { + name: 'number.multipleOf(5)', + schema: z.number().multipleOf(5), + input: [true, { type: 'number', multipleOf: 5 }], + }, + { + name: 'number.gt(6).lt(10)', + schema: z.number().gt(6).lt(10), + input: [true, { type: 'number', exclusiveMinimum: 6, exclusiveMaximum: 10 }], + }, + { + name: 'bigint', + schema: z.bigint(), + input: [true, { type: 'string', pattern: '^-?[0-9]+$' }], + }, + { + name: 'nan', + schema: z.nan(), + input: [true, { not: {} }], + output: [true, { type: 'null' }], + }, +]) diff --git a/packages/zod/src/v4/converter.processed.test.ts b/packages/zod/src/v4/converter.processed.test.ts new file mode 100644 index 000000000..d2458d81d --- /dev/null +++ b/packages/zod/src/v4/converter.processed.test.ts @@ -0,0 +1,21 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'lazy(() => z.object({ value: z.string() }))', + schema: z.lazy(() => z.object({ value: z.string() })), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + }, + { + name: 'lazy(() => z.object({ value: z.lazy(() => z.string()) }))', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.string()) })), + input: [true, { type: 'object', properties: { value: { } } }], + }, + { + name: 'string().transform(x => x)', + schema: z.string().transform(x => x), + input: [true, { type: 'string' }], + output: [false, {}], + }, +]) diff --git a/packages/zod/src/v4/converter.rest.test.ts b/packages/zod/src/v4/converter.rest.test.ts new file mode 100644 index 000000000..a8693abf8 --- /dev/null +++ b/packages/zod/src/v4/converter.rest.test.ts @@ -0,0 +1,20 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'z.symbol()', + schema: z.symbol(), + input: [true, { not: {} }], + }, + { + name: 'z.promise(z.string())', + schema: z.promise(z.string()), + input: [true, { not: {} }], + }, + { + name: 'z.custom(() => false)', + schema: z.custom(() => false), + input: [true, { not: {} }], + }, +]) diff --git a/packages/zod/src/v4/converter.string.test.ts b/packages/zod/src/v4/converter.string.test.ts new file mode 100644 index 000000000..46a535108 --- /dev/null +++ b/packages/zod/src/v4/converter.string.test.ts @@ -0,0 +1,146 @@ +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'string', + schema: z.string(), + input: [true, { type: 'string' }], + }, + { + name: 'string.min(5).max(10).regex(/^[a-z\\]+$/)', + schema: z.string().min(5).max(10).regex(/^[a-z\\]+$/), + input: [true, { type: 'string', maxLength: 10, minLength: 5, pattern: '^[a-z\\\\]+$' }], + }, + { + name: 'base64', + schema: z.base64(), + input: [true, { type: 'string', contentEncoding: 'base64' }], + }, + { + name: 'cuid', + schema: z.cuid(), + input: [true, { type: 'string', pattern: '^[cC][^\\s-]{8,}$' }], + }, + { + name: 'email', + schema: z.email(), + input: [true, { type: 'string', format: 'email' }], + }, + { + name: 'url', + schema: z.url(), + input: [true, { type: 'string', format: 'uri' }], + }, + { + name: 'uuid', + schema: z.uuid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'string.length(6)', + schema: z.string().length(6), + input: [true, { type: 'string', minLength: 6, maxLength: 6 }], + }, + { + name: 'string.includes("a\\")', + schema: z.string().includes('a\\'), + input: [true, { type: 'string', pattern: 'a\\\\' }], + }, + { + name: 'string.startsWith("a\\")', + schema: z.string().startsWith('a\\'), + input: [true, { type: 'string', pattern: '^a\\\\.*' }], + }, + { + name: 'string.endsWith("a\\")', + schema: z.string().endsWith('a\\'), + input: [true, { type: 'string', pattern: '.*a\\\\$' }], + }, + { + name: 'emoji', + schema: z.emoji(), + input: [true, { type: 'string', pattern: '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' }], + }, + { + name: 'uuid', + schema: z.uuid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'guid', + schema: z.guid(), + input: [true, { type: 'string', format: 'uuid' }], + }, + { + name: 'nanoid', + schema: z.nanoid(), + input: [true, { type: 'string', pattern: '^[a-zA-Z0-9_-]{21}$' }], + }, + { + name: 'cuid2', + schema: z.cuid2(), + input: [true, { type: 'string', pattern: '^[0-9a-z]+$' }], + }, + { + name: 'ulid', + schema: z.ulid(), + input: [true, { + type: 'string', + pattern: '^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$', + }], + }, + { + name: 'iso.datetime', + schema: z.iso.datetime(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + name: 'iso.date', + schema: z.iso.date(), + input: [true, { type: 'string', format: 'date' }], + }, + { + name: 'iso.time', + schema: z.iso.time(), + input: [true, { type: 'string', format: 'time' }], + }, + { + name: 'iso.duration', + schema: z.iso.duration(), + input: [true, { type: 'string', format: 'duration' }], + }, + { + name: 'ipv4', + schema: z.ipv4(), + input: [true, { type: 'string', format: 'ipv4' }], + }, + { + name: 'ipv6', + schema: z.ipv6(), + input: [true, { + type: 'string', + format: 'ipv6', + }], + }, + { + name: 'jwt', + schema: z.jwt(), + input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]+$' }], + }, + { + name: 'base64url', + schema: z.base64url(), + input: [true, { type: 'string', pattern: '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' }], + }, + { + name: 'string.trim()', + schema: z.string().trim(), + input: [true, { type: 'string' }], + }, + { + name: 'templateLiteral(z.number(), z.enum(["px", "em", "rem", "%"]))', + schema: z.templateLiteral([z.number(), z.enum(['px', 'em', 'rem', '%'])]) as any, + input: [true, { type: 'string', pattern: '^-?\\d+(?:\\.\\d+)?(px|em|rem|%)$' }], + }, +]) diff --git a/packages/zod/src/v4/converter.structure.test.ts b/packages/zod/src/v4/converter.structure.test.ts new file mode 100644 index 000000000..7c8cccbc4 --- /dev/null +++ b/packages/zod/src/v4/converter.structure.test.ts @@ -0,0 +1,115 @@ +import * as zm from '@zod/mini' +import z from 'zod4' +import { testSchemaConverter } from '../../tests/shared' + +testSchemaConverter([ + { + name: 'array(z.string())', + schema: z.array(z.string()), + input: [true, { type: 'array', items: { type: 'string' } }], + }, + { + name: 'array(z.string()).nonempty()', + schema: z.array(z.string()).nonempty(), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 1 }], + }, + { + name: 'array(z.string()).min(10).max(20)', + schema: z.array(z.string()).min(10).max(20), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 20 }], + }, + { + name: 'array(z.string()).length(10)', + schema: z.array(z.string()).length(10), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 10 }], + }, + { + name: 'array(z.string().optional())', + schema: z.array(z.string().optional()), + input: [true, { type: 'array', items: { type: 'string' } }], + output: [true, { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'null' }] } }], + }, + { + name: 'array(z.undefined())', + schema: z.array(z.undefined()), + input: [true, { type: 'array', items: { not: {} } }], + output: [true, { type: 'array', items: { type: 'null' } }], + }, + { + name: 'tuple([z.enum(["a", "b"])])', + schema: z.tuple([z.enum(['a', 'b'])]), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }] }], + }, + { + name: 'tuple([z.enum(["a", "b"])], z.string())', + schema: z.tuple([z.enum(['a', 'b'])], z.string()), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' } }], + }, + { + name: 'zm.tuple([zm.enum(["a", "b"])], zm.string()).check(zm.minLength(4), zm.maxLength(10))', + schema: zm.tuple([zm.enum(['a', 'b'])], zm.string()).check(zm.minLength(4), zm.maxLength(10)), + input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }], + }, + { + name: 'set(z.string())', + schema: z.set(z.string()), + input: [true, { type: 'array', uniqueItems: true, items: { type: 'string' } }], + }, + { + name: 'set(z.string().optional())', + schema: z.set(z.string().optional()), + input: [true, { type: 'array', uniqueItems: true, items: { type: 'string' } }], + output: [true, { type: 'array', uniqueItems: true, items: { anyOf: [{ type: 'string' }, { type: 'null' }] } }], + }, + { + name: 'object({ value: z.string() })', + schema: z.object({ value: z.string() }), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + }, + { + name: 'object({ value: z.string().optional() })', + schema: z.object({ value: z.string().optional() }), + input: [true, { type: 'object', properties: { value: { type: 'string' } } }], + }, + { + name: 'object({ value: z.undefined() })', + schema: z.object({ value: z.undefined() }), + input: [true, { type: 'object', properties: { value: { not: {} } } }], + }, + { + name: 'object({ value: z.string() }).strict()', + schema: z.object({ value: z.string() }).strict(), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: false }], + }, + { + name: 'object({ value: z.string() }).catchall(z.number())', + schema: z.object({ value: z.string() }).catchall(z.number()), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: { type: 'number' } }], + }, + { + name: 'record(z.number(), z.string())', + schema: z.record(z.number(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'number' } }], + }, + { + name: 'record(z.iso.date(), z.string())', + schema: z.record(z.iso.date(), z.string()), + input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'string', format: 'date' } }], + }, + { + name: 'record(z.string(), z.number().optional())', + schema: z.record(z.string(), z.number().optional()), + input: [true, { type: 'object', additionalProperties: { type: 'number' }, propertyNames: { type: 'string' } }], + }, + { + name: 'map(z.string(), z.number())', + schema: z.map(z.string(), z.number()), + input: [true, { type: 'array', items: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], maxItems: 2, minItems: 2 } }], + }, + { + name: 'map(z.string().optional(), z.number().optional())', + schema: z.map(z.string().optional(), z.number().optional()), + input: [true, { type: 'array', items: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }], maxItems: 2, minItems: 2 } }], + output: [true, { type: 'array', items: { type: 'array', prefixItems: [{ anyOf: [{ type: 'string' }, { type: 'null' }] }, { anyOf: [{ type: 'number' }, { type: 'null' }] }], maxItems: 2, minItems: 2 } }], + }, +]) diff --git a/packages/zod/src/v4/converter.test.ts b/packages/zod/src/v4/converter.test.ts index c4385e18d..e9b7c89b1 100644 --- a/packages/zod/src/v4/converter.test.ts +++ b/packages/zod/src/v4/converter.test.ts @@ -1,499 +1,40 @@ -import type { JSONSchema } from '@orpc/openapi' +import * as zm from '@zod/mini' import * as z from 'zod4' import { ZodToJsonSchemaConverter } from './converter' -type SchemaTestCase = { - schema: z.ZodType - input: [boolean, Exclude] - output?: [boolean, Exclude] -} - -const stringCases: SchemaTestCase[] = [ - { - schema: z.string(), - input: [true, { type: 'string' }], - }, - { - schema: z.string().min(5).max(10).regex(/^[a-z\\]+$/), - input: [true, { type: 'string', maxLength: 10, minLength: 5, pattern: '^[a-z\\\\]+$' }], - }, - { - schema: z.base64(), - input: [true, { type: 'string', contentEncoding: 'base64' }], - }, - { - schema: z.cuid(), - input: [true, { type: 'string', pattern: '^[cC][^\\s-]{8,}$' }], - }, - { - schema: z.email(), - input: [true, { type: 'string', format: 'email' }], - }, - { - schema: z.url(), - input: [true, { type: 'string', format: 'uri' }], - }, - { - schema: z.uuid(), - input: [true, { type: 'string', format: 'uuid' }], - }, - { - schema: z.string().length(6), - input: [true, { type: 'string', minLength: 6, maxLength: 6 }], - }, - { - schema: z.string().includes('a\\'), - input: [true, { type: 'string', pattern: 'a\\\\' }], - }, - { - schema: z.string().startsWith('a\\'), - input: [true, { type: 'string', pattern: '^a\\\\.*' }], - }, - { - schema: z.string().endsWith('a\\'), - input: [true, { type: 'string', pattern: '.*a\\\\$' }], - }, - { - schema: z.emoji(), - input: [true, { type: 'string', pattern: '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' }], - }, - { - schema: z.nanoid(), - input: [true, { type: 'string', pattern: '^[a-zA-Z0-9_-]{21}$' }], - }, - { - schema: z.cuid2(), - input: [true, { type: 'string', pattern: '^[0-9a-z]+$' }], - }, - { - schema: z.ulid(), - input: [true, { - type: 'string', - pattern: '^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$', - }], - }, - { - schema: z.iso.datetime(), - input: [true, { type: 'string', format: 'date-time' }], - }, - { - schema: z.iso.date(), - input: [true, { type: 'string', format: 'date' }], - }, - { - schema: z.iso.time(), - input: [true, { type: 'string', format: 'time' }], - }, - { - schema: z.iso.duration(), - input: [true, { type: 'string', format: 'duration' }], - }, - { - schema: z.ipv4(), - input: [true, { type: 'string', format: 'ipv4' }], - }, - { - schema: z.ipv6(), - input: [true, { - type: 'string', - format: 'ipv6', - }], - }, - { - schema: z.jwt(), - input: [true, { type: 'string', pattern: '^[\\w-]+\\.[\\w-]+\\.[\\w-]+$' }], - }, - { - schema: z.base64url(), - input: [true, { type: 'string', pattern: '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' }], - }, - { - schema: z.string().trim(), - input: [true, { type: 'string' }], - }, -] - -const numberCases: SchemaTestCase[] = [ - { - schema: z.number(), - input: [true, { type: 'number' }], - }, - { - schema: z.number().int(), - input: [true, { type: 'integer' }], - }, - { - schema: z.number().min(0).max(100).int(), - input: [true, { type: 'integer', minimum: 0, maximum: 100 }], - }, - { - schema: z.number().multipleOf(5), - input: [true, { type: 'number', multipleOf: 5 }], - }, - { - schema: z.number(), - input: [true, { type: 'number' }], - }, - { - schema: z.bigint(), - input: [true, { type: 'string', pattern: '^-?[0-9]+$' }], - }, - { - schema: z.nan(), - input: [true, { not: {} }], - output: [true, { type: 'null' }], - }, -] - -enum ExampleEnum { - A = 'a', - B = 'b', -} - -const nativeCases: SchemaTestCase[] = [ - { - schema: z.boolean(), - input: [true, { type: 'boolean' }], - }, - { - schema: z.date(), - input: [true, { type: 'string', format: 'date-time' }], - }, - { - schema: z.null(), - input: [true, { type: 'null' }], - }, - { - schema: z.any(), - input: [false, { }], - }, - { - schema: z.unknown(), - input: [false, {}], - }, - { - schema: z.undefined(), - input: [false, { not: {} }], - }, - { - schema: z.void(), - input: [false, { not: {} }], - }, - { - schema: z.literal(1234), - input: [true, { const: 1234 }], - }, - { - schema: z.literal(undefined), - input: [false, { not: {} }], - }, - { - schema: z.enum(['a', 'b']), - input: [true, { enum: ['a', 'b'] }], - }, - { - schema: z.nativeEnum(ExampleEnum), - input: [true, { enum: ['a', 'b'] }], - }, -] - -const combinationCases: SchemaTestCase[] = [ - { - schema: z.union([z.string(), z.number()]), - input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], - }, - { - schema: z.union([z.string(), z.number().optional()]), - input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], - }, - { - schema: z.union([z.string(), z.undefined()]), - input: [false, { type: 'string' }], - }, - { - schema: z.intersection(z.string(), z.number()), - input: [true, { allOf: [{ type: 'string' }, { type: 'number' }] }], - }, - { - schema: z.intersection(z.string().optional(), z.number().optional()), - input: [false, { allOf: [{ type: 'string' }, { type: 'number' }] }], - }, -] - -const processedCases: SchemaTestCase[] = [ - { - schema: z.lazy(() => z.object({ value: z.string() })), - input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], - }, - { - schema: z.lazy(() => z.object({ value: z.lazy(() => z.string()) })), - input: [true, { type: 'object', properties: { value: { } } }], - }, - { - schema: z.string().transform(x => x), - input: [true, { type: 'string' }], - output: [false, {}], - }, - { - schema: z.string().refine(x => x.length > 0, 'not empty'), - input: [true, { type: 'string' }], - }, - { - schema: z.preprocess(x => x, z.string()), - input: [true, { type: 'string' }], - }, - { - schema: z.number().catch(1), - input: [true, { type: 'number' }], - }, - { - schema: z.number().brand<'CAT'>(), - input: [true, { type: 'number' }], - }, - { - schema: z.number().brand<'CAT'>(), - input: [true, { type: 'number' }], - }, - // { - // schema: z.pipeline(z.number(), z.string()), - // input: [true, { type: 'number' }], - // output: [true, { type: 'string' }], - // }, - { - schema: z.string().nullable(), - input: [true, { anyOf: [{ type: 'null' }, { type: 'string' }] }], - }, - { - schema: z.string().default('a'), - input: [false, { default: 'a', type: 'string' }], - }, - { - schema: z.number().readonly(), - input: [true, { type: 'number', readOnly: true }], - }, -] - -const unsupportedCases: SchemaTestCase[] = [ - { - schema: z.promise(z.string()), - input: [true, { not: {} }], - }, - { - schema: z.symbol(), - input: [true, { not: {} }], - }, - // { - // schema: z.function(), - // input: [true, { not: {} }], - // ignoreZodToJsonSchema: true, - // }, - { - schema: z.never(), - input: [true, { not: {} }], - }, -] - -// const extendSchemaCases: SchemaTestCase[] = [ -// { -// schema: file(), -// input: [true, { type: 'string', contentMediaType: '*/*' }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: file().type('image/png'), -// input: [true, { type: 'string', contentMediaType: 'image/png' }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: blob(), -// input: [true, { type: 'string', contentMediaType: '*/*' }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: regexp(), -// input: [true, { type: 'string', pattern: '^\\/(.*)\\/([a-z]*)$' }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: url(), -// input: [true, { type: 'string', format: 'uri' }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: customJsonSchema(z.string(), { examples: ['a', 'b'] }), -// input: [true, { type: 'string', examples: ['a', 'b'] }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: customJsonSchema( -// customJsonSchema( -// customJsonSchema(z.string(), { examples: ['both'] }), -// { examples: ['input'] }, -// { strategy: 'input' }, -// ), -// { examples: ['output'] }, -// { strategy: 'output' }, -// ), -// input: [true, { type: 'string', examples: ['input'] }], -// output: [true, { type: 'string', examples: ['output'] }], -// ignoreZodToJsonSchema: true, -// }, -// { -// schema: z.string().describe('description'), -// input: [true, { type: 'string', description: 'description' }], -// ignoreZodToJsonSchema: true, -// }, -// ] - -const edgeCases: SchemaTestCase[] = [ - { - schema: z.array(z.string()).nonempty(), - input: [true, { type: 'array', items: { type: 'string' }, minItems: 1 }], - }, - { - schema: z.array(z.string()).min(10).max(20), - input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 20 }], - }, - { - schema: z.array(z.string()).length(10), - input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 10 }], - }, - { - schema: z.object({ value: z.string() }).strict(), - input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: false }], - }, - { - schema: z.object({ value: z.string() }).catchall(z.number()), - input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'], additionalProperties: { type: 'number' } }], - }, - { - schema: z.record(z.number(), z.string()), - input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'number' } }], - }, - { - schema: z.record(z.string().date(), z.string()), - input: [true, { type: 'object', additionalProperties: { type: 'string' }, propertyNames: { type: 'string', format: 'date' } }], - }, -] - -describe.each([ - ...stringCases, - ...numberCases, - // ...nativeCases, - // ...combinationCases, - // ...processedCases, - // ...extendSchemaCases, - // ...unsupportedCases, - // ...edgeCases, -])('zodToJsonSchemaConverter.convert %#', ({ schema, input, output = input }) => { - describe.each([['input'], ['output']] as const)('strategy: %s', (strategy) => { - const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) - - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output - const arrayItemJsonSchema = expectedRequired - ? expectedJson - : strategy === 'input' - ? { anyOf: [expectedJson, { not: {} }] } - : { anyOf: [expectedJson, { type: 'null' }] } - - it('flat', async () => { - const [required, json] = await converter.convert(schema, { strategy }) - - expect(required).toEqual(expectedRequired) - expect(json).toEqual(expectedJson) - }) - - it('object', async () => { - const testSchema = z.object({ value: schema }) - const [required, json] = await converter.convert(testSchema, { strategy }) - - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'object', - properties: { - value: expectedJson, - }, - required: expectedRequired ? ['value'] : undefined, - }) - }) +describe('zodToJsonSchemaConverter', () => { + const converter = new ZodToJsonSchemaConverter() - it('array', async () => { - const testSchema = z.array(schema) - const [required, json] = await converter.convert(testSchema, { strategy }) + it('.condition', async () => { + expect(converter.condition(z.string())).toBe(true) + expect(converter.condition(z.string().optional())).toBe(true) - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'array', - items: arrayItemJsonSchema, - }) - }) + const v = await import('valibot') - it('tuple', async () => { - const testSchema = z.tuple([schema, schema]).rest(schema) - const [required, json] = await converter.convert(testSchema, { strategy }) + expect(converter.condition(v.string())).toBe(false) + }) - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'array', - prefixItems: [ - arrayItemJsonSchema, - arrayItemJsonSchema, - ], - items: arrayItemJsonSchema, - }) + it('@zod/mini', async () => { + const schema = zm.object({ + value: zm.string().check(zm.minLength(5), zm.maxLength(10), zm.regex(/^[a-z\\]+$/)), }) - it('set', async () => { - const testSchema = z.set(schema) - const [required, json] = await converter.convert(testSchema, { strategy }) - - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'array', - uniqueItems: true, - items: arrayItemJsonSchema, - }) - }) + expect(converter.condition(schema)).toBe(true) - it('map', async () => { - const testSchema = z.map(schema, schema.optional()) - const [required, json] = await converter.convert(testSchema, { strategy }) + const [required, json] = await converter.convert(schema, { strategy: 'input' }) - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'array', - items: { - type: 'array', - maxItems: 2, - minItems: 2, - prefixItems: [ - arrayItemJsonSchema, - strategy === 'input' ? expectedJson : { anyOf: [expectedJson, { type: 'null' }] }, - ], + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + properties: { + value: { + type: 'string', + maxLength: 10, + minLength: 5, + pattern: '^[a-z\\\\]+$', }, - }) - }) - - it('record', async () => { - const testSchema = z.record(z.string().regex(/^\d+$/), schema) - const [required, json] = await converter.convert(testSchema, { strategy }) - - expect(required).toEqual(true) - expect(json).toEqual({ - type: 'object', - additionalProperties: expectedJson, - propertyNames: { type: 'string', pattern: '^\\d+$' }, - }) + }, + required: ['value'], }) }) }) - -it('zodToJsonSchemaConverter.condition', async () => { - const converter = new ZodToJsonSchemaConverter() - expect(converter.condition(z.string())).toBe(true) - expect(converter.condition(z.string().optional())).toBe(true) - - const v = await import('valibot') - - expect(converter.condition(v.string())).toBe(false) -}) diff --git a/packages/zod/src/v4/converter.ts b/packages/zod/src/v4/converter.ts index 70d2ce1a2..d022effb6 100644 --- a/packages/zod/src/v4/converter.ts +++ b/packages/zod/src/v4/converter.ts @@ -291,7 +291,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'union': { const union = schema as $ZodUnion - const json: JSONSchema & { anyOf: Exclude[] } = { anyOf: [] } + const anyOf: Exclude[] = [] let required = true @@ -304,17 +304,17 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { if (options.strategy === 'input') { if (itemJson !== this.undefinedJsonSchema && itemJson !== this.unsupportedJsonSchema) { - json.anyOf.push(itemJson) + anyOf.push(itemJson) } } else { if (itemJson !== this.undefinedJsonSchema) { - json.anyOf.push(itemJson) + anyOf.push(itemJson) } } } - return [required, json] + return [required, anyOf.length === 1 ? anyOf[0]! : { anyOf }] } case 'intersection': { @@ -473,9 +473,9 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'default': { const default_ = schema as $ZodDefault - const [required, json] = await this.#convert(default_._zod.def.innerType, options, lazyDepth) + const [, json] = await this.#convert(default_._zod.def.innerType, options, lazyDepth) - return [required, { + return [false, { ...json, default: default_._zod.def.defaultValue(), }] @@ -483,20 +483,8 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'catch': { const catch_ = schema as $ZodCatch - const [required, json] = await this.#convert(catch_._zod.def.innerType, options, lazyDepth) - - let defaultValue: Exclude | undefined - - try { - defaultValue = { default: catch_._zod.def.catchValue(undefined as any) } - } - catch { - } - - return [required, { - ...json, - ...defaultValue, - }] + const [,json] = await this.#convert(catch_._zod.def.innerType, options, lazyDepth) + return [false, json] } case 'nan': { @@ -532,7 +520,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'lazy': { const lazy = schema as $ZodLazy - if (lazyDepth > this.maxLazyDepth) { + if (lazyDepth >= this.maxLazyDepth) { return [false, this.anyJsonSchema] } @@ -601,10 +589,6 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { return JSONSchemaFormat.DateTime } - if (format === 'json_string') { - return 'json-string' - } - return Object.values(JSONSchemaFormat).includes(format as any) ? format : undefined diff --git a/packages/zod/tests/shared.ts b/packages/zod/tests/shared.ts new file mode 100644 index 000000000..039f53c81 --- /dev/null +++ b/packages/zod/tests/shared.ts @@ -0,0 +1,22 @@ +import type { AnySchema } from '@orpc/contract' +import type { JSONSchema } from '@orpc/openapi' +import { ZodToJsonSchemaConverter } from '../src/v4' + +export interface SchemaConverterTestCase { + name: string + schema: AnySchema + input: [boolean, JSONSchema] + output?: [boolean, JSONSchema] +} + +export function testSchemaConverter(cases: SchemaConverterTestCase[]) { + const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) + describe.each([['input'], ['output']] as const)('ZodToJsonSchemaConverter.converter: strategy = %s', (strategy) => { + it.each(cases)(`$name (${strategy})`, async ({ schema, input, output = input }) => { + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = await converter.convert(schema, { strategy }) + expect(json).toEqual(expectedJson) + expect(required).toEqual(expectedRequired) + }) + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 195f1158d..92cdc2b33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,8 +508,8 @@ importers: version: 5.1.4 devDependencies: '@zod/core': - specifier: ^0.10.0 - version: 0.10.0 + specifier: ^0.10.1 + version: 0.10.1 '@zod/mini': specifier: ^@zod/mini@4.0.0-beta.20250503T014749 version: link:^@zod/mini@4.0.0-beta.20250503T014749 @@ -3148,9 +3148,6 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} - '@zod/core@0.10.0': - resolution: {integrity: sha512-iMITRygme3v9jPsITJjvRMw60+MQq7MWnNpJleRkfjeSCjBm3c1/tiw3NUS4re/M2CBXVP5kAjI7sQrf22twXA==} - '@zod/core@0.10.1': resolution: {integrity: sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g==} @@ -9859,8 +9856,6 @@ snapshots: transitivePeerDependencies: - typescript - '@zod/core@0.10.0': {} - '@zod/core@0.10.1': {} abbrev@2.0.0: {} From 9b1670bbc89368b2e6b22c84d139ce05d82bcaba Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 4 May 2025 11:23:23 +0700 Subject: [PATCH 05/13] docs --- .../docs/openapi/openapi-specification.md | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index c6b2e5f5a..969bd3e6e 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -70,7 +70,9 @@ It's recommended to use the built-in converters because the oRPC implementations import { contract, router } from './shared/planet' // ---cut--- import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' +import { + ZodToJsonSchemaConverter +} from '@orpc/zod/v4' // @orpc/zod if you use Zod v3 import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot' @@ -163,7 +165,64 @@ The `.spec` helper accepts a callback as its second argument, allowing you to ov ## `@orpc/zod` -### File Schema +### Zod v4 + +#### File Schema + +Zod v4 includes a native `File` schema. oRPC will detect it automatically - no extra setup needed: + +```ts +import * as z from 'zod' + +const InputSchema = z.object({ + file: oz.file(), + image: oz.file().mine(['image/png', 'image/jpeg']), +}) +``` + +#### JSON Schema Customization + +`description` and `examples` metadata are supported out of the box: + +```ts +import * as z from 'zod' + +const InputSchema = z.object({ + name: z.string(), +}).meta({ + description: 'User schema', + examples: [{ name: 'John' }], +}) +``` + +For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY`: + +```ts +import * as z from 'zod' +import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/v4' + +export const InputSchema = z.object({ + name: z.string(), +}) + +JSON_SCHEMA_REGISTRY.add(InputSchema, { + description: 'User schema', + examples: [{ name: 'John' }], + // other options... +}) + +JSON_SCHEMA_INPUT_REGISTRY.add(InputSchema, { + // only for .input +}) + +JSON_SCHEMA_OUTPUT_REGISTRY.add(InputSchema, { + // only for .output +}) +``` + +### Zod v3 + +#### File Schema In the [File Upload/Download](/docs/file-upload-download) guide, `z.instanceof` is used to describe file/blob schemas. However, this method prevents oRPC from recognizing file/blob schema. Instead, use the enhanced file schema approach: @@ -178,7 +237,7 @@ const InputSchema = z.object({ }) ``` -### JSON Schema Customization +#### JSON Schema Customization If Zod alone does not cover your JSON Schema requirements, you can extend or override the generated schema: From 61db4e1b980d558ac261813c26702de8d0406585 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 4 May 2025 21:26:12 +0700 Subject: [PATCH 06/13] fix packages version --- packages/zod/package.json | 2 +- pnpm-lock.yaml | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/zod/package.json b/packages/zod/package.json index f4e2cfc14..e7d78199f 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -61,7 +61,7 @@ }, "devDependencies": { "@zod/core": "^0.10.1", - "@zod/mini": "^@zod/mini@4.0.0-beta.20250503T014749", + "@zod/mini": "^4.0.0-beta.20250503T014749", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod4": "npm:zod@^4.0.0-beta.20250503T014749" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92cdc2b33..8c9f2aee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,8 +511,8 @@ importers: specifier: ^0.10.1 version: 0.10.1 '@zod/mini': - specifier: ^@zod/mini@4.0.0-beta.20250503T014749 - version: link:^@zod/mini@4.0.0-beta.20250503T014749 + specifier: ^4.0.0-beta.20250503T014749 + version: 4.0.0-beta.20250503T014749 zod: specifier: ^3.24.2 version: 3.24.2 @@ -3151,6 +3151,9 @@ packages: '@zod/core@0.10.1': resolution: {integrity: sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g==} + '@zod/mini@4.0.0-beta.20250503T014749': + resolution: {integrity: sha512-qKqZWOQiSZZ/tekGTrmMQGI+PURd/wJyK4f/gKTWGXT9xbfIVIb6gHEHHA/+iTKJplK3cPiUn7iBlqLmUwgQcQ==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -9858,6 +9861,10 @@ snapshots: '@zod/core@0.10.1': {} + '@zod/mini@4.0.0-beta.20250503T014749': + dependencies: + '@zod/core': 0.10.1 + abbrev@2.0.0: {} abbrev@3.0.1: {} From 038a9879386932d024396a00b7aa984a9e51d0f9 Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 08:37:09 +0700 Subject: [PATCH 07/13] v4 -> zod4 --- apps/content/docs/openapi/openapi-specification.md | 4 ++-- packages/zod/package.json | 10 +++++----- .../zod/src/{v4 => zod4}/converter.combination.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.meta.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.native.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.number.test.ts | 0 .../zod/src/{v4 => zod4}/converter.processed.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.rest.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.string.test.ts | 0 .../zod/src/{v4 => zod4}/converter.structure.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.test.ts | 0 packages/zod/src/{v4 => zod4}/converter.ts | 0 packages/zod/src/{v4 => zod4}/index.ts | 0 packages/zod/src/{v4 => zod4}/registries.ts | 6 +++--- 14 files changed, 10 insertions(+), 10 deletions(-) rename packages/zod/src/{v4 => zod4}/converter.combination.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.meta.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.native.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.number.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.processed.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.rest.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.string.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.structure.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.test.ts (100%) rename packages/zod/src/{v4 => zod4}/converter.ts (100%) rename packages/zod/src/{v4 => zod4}/index.ts (100%) rename packages/zod/src/{v4 => zod4}/registries.ts (88%) diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index 969bd3e6e..f07784f7f 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -72,7 +72,7 @@ import { contract, router } from './shared/planet' import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter -} from '@orpc/zod/v4' // @orpc/zod if you use Zod v3 +} from '@orpc/zod/zod4' // @orpc/zod if you use Zod v3 import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot' @@ -199,7 +199,7 @@ For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_ ```ts import * as z from 'zod' -import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/v4' +import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4' export const InputSchema = z.object({ name: z.string(), diff --git a/packages/zod/package.json b/packages/zod/package.json index e7d78199f..fddf6da8e 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -20,16 +20,16 @@ "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, - "./v4": { - "types": "./dist/v4/index.d.mts", - "import": "./dist/v4/index.mjs", - "default": "./dist/v4/index.mjs" + "./zod4": { + "types": "./dist/zod4/index.d.mts", + "import": "./dist/zod4/index.mjs", + "default": "./dist/zod4/index.mjs" } } }, "exports": { ".": "./src/index.ts", - "./v4": "./src/v4/index.ts" + "./zod4": "./src/zod4/index.ts" }, "files": [ "dist" diff --git a/packages/zod/src/v4/converter.combination.test.ts b/packages/zod/src/zod4/converter.combination.test.ts similarity index 100% rename from packages/zod/src/v4/converter.combination.test.ts rename to packages/zod/src/zod4/converter.combination.test.ts diff --git a/packages/zod/src/v4/converter.meta.test.ts b/packages/zod/src/zod4/converter.meta.test.ts similarity index 100% rename from packages/zod/src/v4/converter.meta.test.ts rename to packages/zod/src/zod4/converter.meta.test.ts diff --git a/packages/zod/src/v4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts similarity index 100% rename from packages/zod/src/v4/converter.native.test.ts rename to packages/zod/src/zod4/converter.native.test.ts diff --git a/packages/zod/src/v4/converter.number.test.ts b/packages/zod/src/zod4/converter.number.test.ts similarity index 100% rename from packages/zod/src/v4/converter.number.test.ts rename to packages/zod/src/zod4/converter.number.test.ts diff --git a/packages/zod/src/v4/converter.processed.test.ts b/packages/zod/src/zod4/converter.processed.test.ts similarity index 100% rename from packages/zod/src/v4/converter.processed.test.ts rename to packages/zod/src/zod4/converter.processed.test.ts diff --git a/packages/zod/src/v4/converter.rest.test.ts b/packages/zod/src/zod4/converter.rest.test.ts similarity index 100% rename from packages/zod/src/v4/converter.rest.test.ts rename to packages/zod/src/zod4/converter.rest.test.ts diff --git a/packages/zod/src/v4/converter.string.test.ts b/packages/zod/src/zod4/converter.string.test.ts similarity index 100% rename from packages/zod/src/v4/converter.string.test.ts rename to packages/zod/src/zod4/converter.string.test.ts diff --git a/packages/zod/src/v4/converter.structure.test.ts b/packages/zod/src/zod4/converter.structure.test.ts similarity index 100% rename from packages/zod/src/v4/converter.structure.test.ts rename to packages/zod/src/zod4/converter.structure.test.ts diff --git a/packages/zod/src/v4/converter.test.ts b/packages/zod/src/zod4/converter.test.ts similarity index 100% rename from packages/zod/src/v4/converter.test.ts rename to packages/zod/src/zod4/converter.test.ts diff --git a/packages/zod/src/v4/converter.ts b/packages/zod/src/zod4/converter.ts similarity index 100% rename from packages/zod/src/v4/converter.ts rename to packages/zod/src/zod4/converter.ts diff --git a/packages/zod/src/v4/index.ts b/packages/zod/src/zod4/index.ts similarity index 100% rename from packages/zod/src/v4/index.ts rename to packages/zod/src/zod4/index.ts diff --git a/packages/zod/src/v4/registries.ts b/packages/zod/src/zod4/registries.ts similarity index 88% rename from packages/zod/src/v4/registries.ts rename to packages/zod/src/zod4/registries.ts index 3deb11849..12080ba1e 100644 --- a/packages/zod/src/v4/registries.ts +++ b/packages/zod/src/zod4/registries.ts @@ -7,7 +7,7 @@ import { registry } from '@zod/core' * * @example * ```ts - * import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/v4' + * import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4' * * const user = z.object({ * name: z.string(), @@ -26,7 +26,7 @@ export const JSON_SCHEMA_REGISTRY = registry, b * * @example * ```ts - * import { JSON_SCHEMA_OUTPUT_REGISTRY } from '@orpc/zod/v4' + * import { JSON_SCHEMA_OUTPUT_REGISTRY } from '@orpc/zod/zod4' * * const user = z.object({ * name: z.string(), From ae6de0752327e72d3051f4d3b051d657837239fd Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 15:29:46 +0700 Subject: [PATCH 08/13] wip --- .../docs/openapi/openapi-specification.md | 5 +- .../openapi/plugins/zod-smart-coercion.md | 7 +- .../zod/src/zod4/coercer.combination.test.ts | 157 ++++++++ packages/zod/src/zod4/coercer.native.test.ts | 178 +++++++++ packages/zod/src/zod4/coercer.rest.test.ts | 17 + .../zod/src/zod4/coercer.structure.test.ts | 179 +++++++++ packages/zod/src/zod4/coercer.test.ts | 35 ++ packages/zod/src/zod4/coercer.ts | 378 ++++++++++++++++++ packages/zod/src/zod4/index.ts | 1 + packages/zod/tests/shared.ts | 39 +- 10 files changed, 992 insertions(+), 4 deletions(-) create mode 100644 packages/zod/src/zod4/coercer.combination.test.ts create mode 100644 packages/zod/src/zod4/coercer.native.test.ts create mode 100644 packages/zod/src/zod4/coercer.rest.test.ts create mode 100644 packages/zod/src/zod4/coercer.structure.test.ts create mode 100644 packages/zod/src/zod4/coercer.test.ts create mode 100644 packages/zod/src/zod4/coercer.ts diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index f07784f7f..1b26bab97 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -72,7 +72,10 @@ import { contract, router } from './shared/planet' import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter -} from '@orpc/zod/zod4' // @orpc/zod if you use Zod v3 +} from '@orpc/zod' // <-- zod v3 +import { + ZodToJsonSchemaConverter +} from '@orpc/zod/zod4' // <-- zod v4 import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot' diff --git a/apps/content/docs/openapi/plugins/zod-smart-coercion.md b/apps/content/docs/openapi/plugins/zod-smart-coercion.md index a5a8bcd2c..d8a51d98c 100644 --- a/apps/content/docs/openapi/plugins/zod-smart-coercion.md +++ b/apps/content/docs/openapi/plugins/zod-smart-coercion.md @@ -7,6 +7,10 @@ description: A refined alternative to `z.coerce` that automatically converts inp A Plugin refined alternative to `z.coerce` that automatically converts inputs to the expected type without modifying the input schema. +::: warning +In Zod v4, this plugin only supports **discriminated unions**. Regular (non-discriminated) unions are **not** coerced automatically. +::: + ## Installation ::: code-group @@ -37,7 +41,8 @@ deno install npm:@orpc/zod@latest ```ts import { OpenAPIHandler } from '@orpc/openapi/fetch' -import { ZodSmartCoercionPlugin } from '@orpc/zod' +import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3 +import { ZodSmartCoercionPlugin } from '@orpc/zod/zod4' // <-- zod v4 const handler = new OpenAPIHandler(router, { plugins: [new ZodSmartCoercionPlugin()] diff --git a/packages/zod/src/zod4/coercer.combination.test.ts b/packages/zod/src/zod4/coercer.combination.test.ts new file mode 100644 index 000000000..534ccab99 --- /dev/null +++ b/packages/zod/src/zod4/coercer.combination.test.ts @@ -0,0 +1,157 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +const InfiniteLazySchema = z.lazy(() => z.object({ boolean: z.boolean(), value: z.lazy(() => InfiniteLazySchema) })) as any + +testSchemaSmartCoercion([ + { + name: 'union - 123 - un-discriminated', + schema: z.union([z.boolean(), z.number()]), + input: '123', + expected: '123', + }, + { + name: 'union - object boolean - un-discriminated', + schema: z.union([z.object({ a: z.boolean() }), z.object({ b: z.number() })]), + input: { a: 'true' }, + expected: { a: 'true' }, + }, + { + name: 'union - only one option', + schema: z.union([z.boolean()]), + input: 'true', + expected: true, + }, + { + name: 'union - one discriminated', + schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ b: z.number() })]), + input: { a: 'type1', b: '123' }, + expected: { a: 'type1', b: 123 }, + }, + { + name: 'union - discriminated', + schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: 'type2', b: '123' }, + expected: { a: 'type2', b: 123n }, + }, + { + name: 'union - complex discriminated 1', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: { v: 'type1' }, b: '123' }, + expected: { a: { v: 'type1' }, b: 123 }, + }, + { + name: 'union - complex discriminated 2', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: 'type1', b: '123' }, + expected: { a: 'type1', b: '123' }, + }, + { + name: 'union - complex discriminated 3', + schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), + input: { a: { v: 'type2' }, b: '123' }, + expected: { a: { v: 'type2' }, b: '123' }, + }, + { + name: 'union - not coerce discriminated key', + schema: z.union([z.object({ a: z.literal(true), b: z.number() }), z.object({ a: z.literal(false), b: z.bigint() })]), + input: { a: 'true', b: '123' }, + expected: { a: 'true', b: '123' }, + }, + { + name: 'intersection - 123', + schema: z.object({ a: z.number() }).and(z.object({ b: z.boolean() })), + input: { a: '1234', b: 'true' }, + expected: { a: 1234, b: true }, + }, + { + name: 'boolean - readonly', + schema: z.boolean().readonly(), + input: 'true', + expected: true, + }, + { + name: 'pipe - boolean', + schema: z.boolean().pipe(z.transform(() => '1')).pipe(z.string()), + input: 'true', + expected: true, + }, + { + name: 'transform - boolean', + schema: z.boolean().transform(() => {}), + input: 'true', + expected: true, + }, + { + name: 'brand - boolean', + schema: z.boolean().brand<'CAT'>(), + input: 'true', + expected: true, + }, + { + name: 'catch - boolean', + schema: z.boolean().catch(false), + input: 'true', + expected: true, + }, + { + name: 'default - boolean', + schema: z.boolean().default(false), + input: 'true', + expected: true, + }, + { + name: 'nullable - boolean', + schema: z.boolean().nullable(), + input: 'true', + expected: true, + }, + { + name: 'nullable - null', + schema: z.boolean().nullable(), + input: null, + expected: null, + }, + { + name: 'optional - boolean', + schema: z.boolean().optional(), + input: 'true', + expected: true, + }, + { + name: 'optional - undefined', + schema: z.boolean().optional(), + input: undefined, + expected: undefined, + }, + { + name: 'optional - non optional - undefined', + schema: z.boolean().optional().nonoptional(), + input: undefined, + expected: undefined, + }, + { + name: 'optional - non optional - true', + schema: z.boolean().optional().nonoptional(), + input: 'on', + expected: true, + }, + { + name: 'lazy - true', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { value: 'true' } }, + expected: { value: { value: true } }, + }, + { + name: 'lazy - invalid', + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { value: 'invalid' } }, + expected: { value: { value: 'invalid' } }, + }, + { + name: 'lazy - InfiniteLazySchema', + schema: InfiniteLazySchema, + input: { value: { boolean: 'true' } }, + expected: { value: { boolean: true } }, + }, +]) diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts new file mode 100644 index 000000000..a3d95d1e0 --- /dev/null +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -0,0 +1,178 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +enum TestEnum { + NUMBER = 123, + STRING = 'string', +} + +testSchemaSmartCoercion([ + { + name: 'number - 12345', + schema: z.number(), + input: '12345', + expected: 12345, + }, + { + name: 'number - -12345', + schema: z.number(), + input: '-12345', + expected: -12345, + }, + { + name: 'number - 12345n', + schema: z.number(), + input: '12345n', + expected: '12345n', + }, + { + name: 'bigint - 12345', + schema: z.bigint(), + input: '12345', + expected: 12345n, + }, + { + name: 'bigint - -12345', + schema: z.bigint(), + input: '-12345', + expected: -12345n, + }, + { + name: 'bigint - 12345n', + schema: z.bigint(), + input: '12345n', + expected: '12345n', + }, + { + name: 'bigint - true', + schema: z.bigint(), + input: true, + expected: true, + }, + { + name: 'boolean - t', + schema: z.boolean(), + input: 't', + expected: true, + }, + { + name: 'boolean - true', + schema: z.boolean(), + input: 'true', + expected: true, + }, + { + name: 'boolean - on', + schema: z.boolean(), + input: 'on', + expected: true, + }, + { + name: 'boolean - ON', + schema: z.boolean(), + input: 'ON', + expected: true, + }, + { + name: 'boolean - f', + schema: z.boolean(), + input: 'f', + expected: false, + }, + { + name: 'boolean - false', + schema: z.boolean(), + input: 'false', + expected: false, + }, + { + name: 'boolean - off', + schema: z.boolean(), + input: 'off', + expected: false, + }, + { + name: 'boolean - OFF', + schema: z.boolean(), + input: 'OFF', + expected: false, + }, + { + name: 'boolean - hi', + schema: z.boolean(), + input: 'hi', + expected: 'hi', + }, + { + name: 'date - iso string', + schema: z.date(), + input: new Date('2023-01-01').toISOString(), + expected: new Date('2023-01-01'), + }, + { + name: 'date - 2023-01-01', + schema: z.date(), + input: '2023-01-01', + expected: new Date('2023-01-01'), + }, + { + name: 'date - 2023-01-01I', + schema: z.date(), + input: '2023-01-01I', + expected: '2023-01-01I', + }, + { + name: 'date - array', + schema: z.date(), + input: [], + expected: [], + }, + { + name: 'literal - 199', + schema: z.literal([199, '199', 200n, undefined]), + input: '199', + expected: '199', + }, + { + name: 'literal - 200', + schema: z.literal([199, '199', 200n, undefined, true]), + input: '200', + expected: 200n, + }, + { + name: 'literal - undefined', + schema: z.literal([199, '199', 200n, undefined, true]), + input: undefined, + expected: undefined, + }, + { + name: 'literal - undefined', + schema: z.literal([199, '199', 200n, undefined, true]), + input: 'true', + expected: true, + }, + { + name: 'nativeEnum - 123', + schema: z.enum(TestEnum), + input: '123', + expected: 123, + }, + { + name: 'nativeEnum - string', + schema: z.enum(TestEnum), + input: 'string', + expected: 'string', + }, + { + name: 'nativeEnum - 123n', + schema: z.enum(TestEnum), + input: '123n', + expected: '123n', + }, + { + name: 'enum - 123', + schema: z.enum(['123', '456']), + input: '123', + expected: '123', + }, +]) diff --git a/packages/zod/src/zod4/coercer.rest.test.ts b/packages/zod/src/zod4/coercer.rest.test.ts new file mode 100644 index 000000000..135d5fb85 --- /dev/null +++ b/packages/zod/src/zod4/coercer.rest.test.ts @@ -0,0 +1,17 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +testSchemaSmartCoercion([ + { + name: 'number - 123', + schema: z.number().or(z.string()), + input: '123', + expected: '123', + }, + { + name: 'boolean - true', + schema: z.boolean().or(z.string()), + input: 'true', + expected: 'true', + }, +]) diff --git a/packages/zod/src/zod4/coercer.structure.test.ts b/packages/zod/src/zod4/coercer.structure.test.ts new file mode 100644 index 000000000..5143e12da --- /dev/null +++ b/packages/zod/src/zod4/coercer.structure.test.ts @@ -0,0 +1,179 @@ +import z from 'zod4' +import { testSchemaSmartCoercion } from '../../tests/shared' + +testSchemaSmartCoercion([ + { + name: 'array - undefined', + schema: z.array(z.boolean()), + input: undefined, + expected: [], + }, + { + name: 'optional array - undefined', + schema: z.array(z.boolean()).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'array - boolean', + schema: z.array(z.boolean()), + input: ['true', 'off', 'invalid'], + expected: [true, false, 'invalid'], + }, + { + name: 'array - object', + schema: z.array(z.boolean()), + input: { a: 1 }, + expected: { a: 1 }, + }, + { + name: 'tuple - undefined', + schema: z.tuple([z.number(), z.boolean()]), + input: undefined, + expected: [], + }, + { + name: 'optional tuple - undefined', + schema: z.tuple([z.number(), z.boolean()]).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'tuple - number, boolean', + schema: z.tuple([z.number(), z.bigint()], z.boolean()), + input: ['123', '123', 'off', 'invalid'], + expected: [123, 123n, false, 'invalid'], + }, + { + name: 'tuple - number', + schema: z.tuple([z.number(), z.bigint()], z.boolean()), + input: 123, + expected: 123, + }, + { + name: 'tuple - without rest', + schema: z.tuple([z.number(), z.bigint()]), + input: ['1', '2', '3', '4'], + expected: [1, 2n, '3', '4'], + }, + { + name: 'set - undefined', + schema: z.set(z.number()), + input: undefined, + expected: new Set(), + }, + { + name: 'optional set - undefined', + schema: z.set(z.number()).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'set - array boolean', + schema: z.set(z.boolean()).optional(), + input: ['true', 'off', 'invalid'], + expected: new Set([true, false, 'invalid']), + }, + { + name: 'set - set boolean', + schema: z.set(z.boolean()).optional(), + input: new Set(['true', 'off', 'invalid']), + expected: new Set([true, false, 'invalid']), + }, + { + name: 'set - map', + schema: z.set(z.number()), + input: new Map([[1, 2]]), + expected: new Map([[1, 2]]), + }, + { + name: 'object - undefined', + schema: z.object({ a: z.boolean() }), + input: undefined, + expected: {}, + }, + { + name: 'optional object - undefined', + schema: z.object({ a: z.boolean() }).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'object - boolean', + schema: z.object({ a: z.boolean() }), + input: { a: 'true' }, + expected: { a: true }, + }, + { + name: 'object - boolean with more fields than needed', + schema: z.object({ a: z.boolean() }), + input: { a: 'true', b: 'off' }, + expected: { a: true, b: 'off' }, + }, + { + name: 'object - boolean with catchall', + schema: z.object({ a: z.boolean() }).catchall(z.number()), + input: { a: 'true', b: 'off', c: '123' }, + expected: { a: true, b: 'off', c: 123 }, + }, + { + name: 'object - array', + schema: z.object({ a: z.boolean() }), + input: [3, 2, 1], + expected: [3, 2, 1], + }, + { + name: 'record - undefined', + schema: z.record(z.string(), z.boolean()), + input: undefined, + expected: {}, + }, + { + name: 'optional record - undefined', + schema: z.record(z.string(), z.boolean()).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'record - undefined', + schema: z.record(z.string(), z.boolean()), + input: { a: 'true', b: 'off' }, + expected: { a: true, b: false }, + }, + { + name: 'record - big int', + schema: z.record(z.string(), z.boolean()), + input: 123n, + expected: 123n, + }, + { + name: 'map - undefined', + schema: z.map(z.boolean(), z.number()), + input: undefined, + expected: new Map(), + }, + { + name: 'optional map - undefined', + schema: z.map(z.boolean(), z.number()).optional(), + input: undefined, + expected: undefined, + }, + { + name: 'map - array', + schema: z.map(z.boolean(), z.number()), + input: [['true', '1'], ['off', 2], ['invalid']], + expected: new Map([[true, 1], [false, 2], ['invalid', undefined]]), + }, + { + name: 'map - map boolean', + schema: z.map(z.boolean(), z.number()), + input: new Map([['true', '1'], ['off', 2], ['invalid']] as any), + expected: new Map([[true, 1], [false, 2], ['invalid', undefined]]), + }, + { + name: 'map - invalid array', + schema: z.map(z.boolean(), z.number()), + input: [1, 2, 3], + expected: [1, 2, 3], + }, +]) diff --git a/packages/zod/src/zod4/coercer.test.ts b/packages/zod/src/zod4/coercer.test.ts new file mode 100644 index 000000000..d940683a3 --- /dev/null +++ b/packages/zod/src/zod4/coercer.test.ts @@ -0,0 +1,35 @@ +import * as z from 'zod4' +import { ZodSmartCoercionPlugin } from './coercer' + +it('zodSmartCoercionPlugin ignore non-zod schemas', async () => { + const plugin = new ZodSmartCoercionPlugin() + const options = {} as any + plugin.init(options) + + const coerce = (schema: any, input: unknown) => { + let coerced: unknown + + options.clientInterceptors[0]({ + procedure: { + '~orpc': { + inputSchema: schema, + }, + }, + input, + next: (options: any) => { + coerced = typeof options === 'object' ? options.input : input + }, + }) + + return coerced + } + + const val = { value: 123 } + + expect(coerce(z.object({}), val)).toEqual(val) + expect(coerce(z.object({}), val)).not.toBe(val) + + const v = await import('valibot') + + expect(coerce(v.object({}), val)).toBe(val) +}) diff --git a/packages/zod/src/zod4/coercer.ts b/packages/zod/src/zod4/coercer.ts new file mode 100644 index 000000000..8939637d6 --- /dev/null +++ b/packages/zod/src/zod4/coercer.ts @@ -0,0 +1,378 @@ +import type { Context } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { $ZodArray, $ZodCatch, $ZodDefault, $ZodEnum, $ZodIntersection, $ZodLazy, $ZodLiteral, $ZodMap, $ZodNonOptional, $ZodNullable, $ZodObject, $ZodOptional, $ZodPipe, $ZodReadonly, $ZodRecord, $ZodSet, $ZodTuple, $ZodType, $ZodUnion, util } from '@zod/core' +import { guard, isObject } from '@orpc/shared' + +export class ZodSmartCoercionPlugin implements StandardHandlerPlugin { + init(options: StandardHandlerOptions): void { + options.clientInterceptors ??= [] + + options.clientInterceptors.unshift((options) => { + const inputSchema = options.procedure['~orpc'].inputSchema + + if (!inputSchema || inputSchema['~standard'].vendor !== 'zod') { + return options.next() + } + + const coercedInput = this.#coerce(inputSchema as $ZodType, options.input) + + return options.next({ ...options, input: coercedInput }) + }) + } + + #coerce(schema: $ZodType, value: unknown): unknown { + switch (schema._zod.def.type) { + case 'number' : { + if (typeof value === 'string') { + return this.#stringToNumber(value) + } + + return value + } + + case 'bigint' : { + if (typeof value === 'string') { + return this.#stringToBigInt(value) + } + + return value + } + + case 'boolean' : + case 'success' : { + if (typeof value === 'string') { + return this.#stringToBoolean(value) + } + + return value + } + + case 'date' : { + if (typeof value === 'string') { + return this.#stringToDate(value) + } + + return value + } + + case 'literal' : + case 'enum': { + const literal = schema as $ZodLiteral | $ZodEnum + + if (!literal._zod.values.has(value as any) && typeof value === 'string') { + const num = this.#stringToNumber(value) + if (literal._zod.values.has(num as any)) { + return num + } + + const bool = this.#stringToBoolean(value) + if (literal._zod.values.has(bool as any)) { + return bool + } + + const bigint = this.#stringToBigInt(value) + if (literal._zod.values.has(bigint as any)) { + return bigint + } + } + + return value + } + + case 'array': { + const array = schema as $ZodArray + + if (value === undefined) { + return [] + } + + if (Array.isArray(value)) { + return value.map(v => this.#coerce(array._zod.def.element, v)) + } + + return value + } + + case 'tuple': { + const tuple = schema as $ZodTuple + + if (value === undefined) { + return [] + } + + if (Array.isArray(value)) { + return value.map((v, i) => { + const s = tuple._zod.def.items[i] ?? tuple._zod.def.rest + return s ? this.#coerce(s, v) : v + }) + } + + return value + } + + case 'set': { + const set = schema as $ZodSet + + if (value === undefined) { + return new Set() + } + + if (Array.isArray(value)) { + return new Set( + value.map(v => this.#coerce(set._zod.def.valueType, v)), + ) + } + + if (value instanceof Set) { + return new Set( + Array.from(value).map(v => this.#coerce(set._zod.def.valueType, v)), + ) + } + + return value + } + + case 'object': + case 'interface': { + const object = schema as $ZodObject + + if (value === undefined) { + return {} + } + + if (isObject(value)) { + const newObj: Record = {} + + const keys = new Set([ + ...Object.keys(value), + ...Object.keys(object._zod.def.shape), + ]) + + for (const k of keys) { + const s = object._zod.def.shape[k] ?? object._zod.def.catchall + newObj[k] = s ? this.#coerce(s, value[k]) : value[k] + } + + return newObj + } + + return value + } + + case 'record': { + const record = schema as $ZodRecord + + if (value === undefined) { + return {} + } + + if (isObject(value)) { + const newObj: Record = {} + + for (const [k, v] of Object.entries(value)) { + const key = this.#coerce(record._zod.def.keyType, k) + const val = this.#coerce(record._zod.def.valueType, v) + newObj[key as any] = val + } + + return newObj + } + + return value + } + + case 'map': { + const map = schema as $ZodMap + + if (value === undefined) { + return new Map() + } + + if (Array.isArray(value) && value.every(i => Array.isArray(i) && i.length <= 2)) { + return new Map( + value.map(([k, v]) => [ + this.#coerce(map._zod.def.keyType, k), + this.#coerce(map._zod.def.valueType, v), + ]), + ) + } + + if (value instanceof Map) { + return new Map( + Array.from(value).map(([k, v]) => [ + this.#coerce(map._zod.def.keyType, k), + this.#coerce(map._zod.def.valueType, v), + ]), + ) + } + + return value + } + + case 'union': { + const union = schema as $ZodUnion + + if (union._zod.def.options.length === 1) { + return this.#coerce(union._zod.def.options[0]!, value) + } + + // support discriminated unions + if (isObject(value)) { + for (const option of union._zod.def.options) { + if (option._zod.disc && this.#matchDiscriminators(value, option._zod.disc)) { + return this.#coerce(option, value) + } + } + } + + return value + } + + case 'intersection': { + const intersection = schema as $ZodIntersection + + return this.#coerce( + intersection._zod.def.right, + this.#coerce(intersection._zod.def.left, value), + ) + } + + case 'optional': { + const optional = schema as $ZodOptional + + if (value === undefined) { + return undefined + } + + return this.#coerce(optional._zod.def.innerType, value) + } + + case 'nonoptional': { + const nonoptional = schema as $ZodNonOptional + return this.#coerce(nonoptional._zod.def.innerType, value) + } + + case 'nullable': { + const nullable = schema as $ZodNullable + + if (value === null) { + return null + } + + return this.#coerce(nullable._zod.def.innerType, value) + } + + case 'readonly': { + const readonly_ = schema as $ZodReadonly + return this.#coerce(readonly_._zod.def.innerType, value) + } + + case 'pipe': { + const pipe = schema as $ZodPipe + return this.#coerce(pipe._zod.def.in, value) + } + + case 'default': + case 'catch': { + const default_ = schema as $ZodDefault | $ZodCatch + return this.#coerce(default_._zod.def.innerType, value) + } + + case 'lazy': { + const lazy = schema as $ZodLazy + + // Prevent infinite loop + if (value !== undefined) { + return this.#coerce(lazy._zod.def.getter(), value) + } + + return value + } + + default: { + const _unsupported: + | 'null' + | 'nan' + | 'transform' + | 'void' + | 'never' + | 'any' + | 'unknown' + | 'file' + | 'undefined' + | 'string' + | 'template_literal' + | 'int' + | 'symbol' + | 'promise' + | 'custom' + = schema._zod.def.type + + return value + } + } + } + + #stringToNumber(value: string): number | string { + const num = Number(value) + return Number.isNaN(num) || num.toString() !== value ? value : num + } + + #stringToBigInt(value: string): bigint | string { + return guard(() => BigInt(value)) ?? value + } + + #stringToBoolean(value: string): boolean | string { + const lower = value.toLowerCase() + + if (lower === 'false' || lower === 'off' || lower === 'f') { + return false + } + + if (lower === 'true' || lower === 'on' || lower === 't') { + return true + } + + return value + } + + #stringToDate(value: string): Date | string { + const date = new Date(value) + + if (!Number.isNaN(date.getTime()) && date.toISOString().startsWith(value)) { + return date + } + + return value + } + + /** + * This function is inspired from Zod, because it's not exported + * https://github.com/colinhacks/zod/blob/v4/packages/core/src/schemas.ts#L1903C1-L1921C2 + */ + #matchDiscriminators(input: Record, discs: util.DiscriminatorMap): boolean { + for (const [key, value] of discs) { + const data = input[key] + + if (value.values.size && !value.values.has(data as any)) { + return false + } + + if (value.maps.length === 0) { + continue + } + + if (!isObject(data)) { + return false + } + + for (const m of value.maps) { + if (!this.#matchDiscriminators(data, m)) { + return false + } + } + } + + return true + } +} diff --git a/packages/zod/src/zod4/index.ts b/packages/zod/src/zod4/index.ts index d9e4c2818..f38d05eee 100644 --- a/packages/zod/src/zod4/index.ts +++ b/packages/zod/src/zod4/index.ts @@ -1,2 +1,3 @@ +export * from './coercer' export * from './converter' export * from './registries' diff --git a/packages/zod/tests/shared.ts b/packages/zod/tests/shared.ts index 039f53c81..5b3c33970 100644 --- a/packages/zod/tests/shared.ts +++ b/packages/zod/tests/shared.ts @@ -1,6 +1,6 @@ import type { AnySchema } from '@orpc/contract' import type { JSONSchema } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '../src/v4' +import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '../src/zod4' export interface SchemaConverterTestCase { name: string @@ -12,7 +12,7 @@ export interface SchemaConverterTestCase { export function testSchemaConverter(cases: SchemaConverterTestCase[]) { const converter = new ZodToJsonSchemaConverter({ maxLazyDepth: 1 }) describe.each([['input'], ['output']] as const)('ZodToJsonSchemaConverter.converter: strategy = %s', (strategy) => { - it.each(cases)(`$name (${strategy})`, async ({ schema, input, output = input }) => { + it.each(cases)('$name', async ({ schema, input, output = input }) => { const [expectedRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = await converter.convert(schema, { strategy }) expect(json).toEqual(expectedJson) @@ -20,3 +20,38 @@ export function testSchemaConverter(cases: SchemaConverterTestCase[]) { }) }) } + +export interface SchemaSmartCoercionTestCase { + name: string + schema: AnySchema + input: unknown + expected: unknown +} + +export function testSchemaSmartCoercion(cases: SchemaSmartCoercionTestCase[]) { + const plugin = new ZodSmartCoercionPlugin() + const options = {} as any + plugin.init(options) + + const coerce = (schema: AnySchema, input: unknown) => { + let coerced: unknown + + options.clientInterceptors[0]({ + procedure: { + '~orpc': { + inputSchema: schema, + }, + }, + input, + next: ({ input }: any) => { + coerced = input + }, + }) + + return coerced + } + + it.each(cases)('$name', ({ schema, input, expected }) => { + expect(coerce(schema, input)).toEqual(expected) + }) +} From f976dcb85422efe76fa103b37b670fffe19610ca Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 15:48:53 +0700 Subject: [PATCH 09/13] upgrade zod version --- packages/zod/package.json | 8 +++---- packages/zod/src/zod4/converter.ts | 8 +++---- pnpm-lock.yaml | 34 +++++++++++++++--------------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/zod/package.json b/packages/zod/package.json index fddf6da8e..02a778302 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -42,7 +42,7 @@ "peerDependencies": { "@orpc/contract": "workspace:*", "@orpc/server": "workspace:*", - "@zod/core": ">=0.10.1", + "@zod/core": ">=0.11.4", "zod": ">=3.24.2" }, "peerDependenciesMeta": { @@ -60,10 +60,10 @@ "wildcard-match": "^5.1.3" }, "devDependencies": { - "@zod/core": "^0.10.1", - "@zod/mini": "^4.0.0-beta.20250503T014749", + "@zod/core": "^0.11.4", + "@zod/mini": "^4.0.0-beta.20250505T012514", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", - "zod4": "npm:zod@^4.0.0-beta.20250503T014749" + "zod4": "npm:zod@^4.0.0-beta.20250505T012514" } } diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index d022effb6..0672b528d 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -7,7 +7,6 @@ import type { $ZodDefault, $ZodEnum, $ZodFile, - $ZodInterface, $ZodIntersection, $ZodLazy, $ZodLiteral, @@ -259,9 +258,8 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { return [true, json] } - case 'object': - case 'interface': { - const object = schema as $ZodObject | $ZodInterface + case 'object': { + const object = schema as $ZodObject const json: JSONSchema & { required?: string[] } = { type: 'object' } for (const [key, value] of Object.entries(object._zod.def.shape)) { @@ -528,7 +526,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { } default: { - const _unsupported: 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type + const _unsupported: 'interface' | 'int' | 'symbol' | 'promise' | 'custom' = schema._zod.def.type return [true, this.unsupportedJsonSchema] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c9f2aee1..9ad8188d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,11 +508,11 @@ importers: version: 5.1.4 devDependencies: '@zod/core': - specifier: ^0.10.1 - version: 0.10.1 + specifier: ^0.11.4 + version: 0.11.4 '@zod/mini': - specifier: ^4.0.0-beta.20250503T014749 - version: 4.0.0-beta.20250503T014749 + specifier: ^4.0.0-beta.20250505T012514 + version: 4.0.0-beta.20250505T012514 zod: specifier: ^3.24.2 version: 3.24.2 @@ -520,8 +520,8 @@ importers: specifier: ^3.24.5 version: 3.24.5(zod@3.24.2) zod4: - specifier: npm:zod@^4.0.0-beta.20250503T014749 - version: zod@4.0.0-beta.20250503T014749 + specifier: npm:zod@^4.0.0-beta.20250505T012514 + version: zod@4.0.0-beta.20250505T012514 playgrounds/contract-first: devDependencies: @@ -3148,11 +3148,11 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} - '@zod/core@0.10.1': - resolution: {integrity: sha512-EmgYiJLMfZ3Dop9Wp7SadkEGYxbjGvrB/qRCT6PhGft9Eh1TbtNQYO9wEBgw4RE9JsmkolZ5Ah+tHu0EwoIy5g==} + '@zod/core@0.11.4': + resolution: {integrity: sha512-ezfAaaxgjSXZw9sH5QJ4/uqFmg8PbwBFtdSlzz1OoXWcSUR4fj4meS491+lk9ZGxCymjJ/pbOSu7nzcxvHtG0g==} - '@zod/mini@4.0.0-beta.20250503T014749': - resolution: {integrity: sha512-qKqZWOQiSZZ/tekGTrmMQGI+PURd/wJyK4f/gKTWGXT9xbfIVIb6gHEHHA/+iTKJplK3cPiUn7iBlqLmUwgQcQ==} + '@zod/mini@4.0.0-beta.20250505T012514': + resolution: {integrity: sha512-BxGk6wZsfi0uJ70Mty7pChMyvawl5qb9KqyvZFez2l/ypI5fPSHZF2sAWKPOd3oM0u3LXPbE3f68dMlLhTGm9A==} abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} @@ -7299,8 +7299,8 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} - zod@4.0.0-beta.20250503T014749: - resolution: {integrity: sha512-ND9JjNpf2IaTZlHr4xgvWbOmzOwjDzrlCqBlhpnYSpXcx6DFzmLJrWhCZc4xgNGieD7MCx/ZoWIHDGZzmg/gnA==} + zod@4.0.0-beta.20250505T012514: + resolution: {integrity: sha512-b9Oif/j2uIFuimTO3xqTZP71cfNcv49G7sSDF8wp4+MH2tSCDgRDy5RKEMbLtD0LTrmGznL/gYqqDW7U80PudA==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9859,11 +9859,11 @@ snapshots: transitivePeerDependencies: - typescript - '@zod/core@0.10.1': {} + '@zod/core@0.11.4': {} - '@zod/mini@4.0.0-beta.20250503T014749': + '@zod/mini@4.0.0-beta.20250505T012514': dependencies: - '@zod/core': 0.10.1 + '@zod/core': 0.11.4 abbrev@2.0.0: {} @@ -14756,8 +14756,8 @@ snapshots: zod@3.24.2: {} - zod@4.0.0-beta.20250503T014749: + zod@4.0.0-beta.20250505T012514: dependencies: - '@zod/core': 0.10.1 + '@zod/core': 0.11.4 zwitch@2.0.4: {} From c6db558823c3454b802f7d83718ad3678dd58b9e Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 15:53:56 +0700 Subject: [PATCH 10/13] experimental_ --- .../docs/openapi/openapi-specification.md | 6 +++-- .../openapi/plugins/zod-smart-coercion.md | 4 +++- packages/zod/src/zod4/coercer.test.ts | 4 +++- packages/zod/src/zod4/coercer.ts | 2 +- packages/zod/src/zod4/converter.test.ts | 4 +++- packages/zod/src/zod4/converter.ts | 22 +++++++++++-------- packages/zod/src/zod4/registries.ts | 6 ++--- packages/zod/tests/shared.ts | 5 ++++- 8 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index 1b26bab97..b1e4fef35 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -74,7 +74,7 @@ import { ZodToJsonSchemaConverter } from '@orpc/zod' // <-- zod v3 import { - ZodToJsonSchemaConverter + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter } from '@orpc/zod/zod4' // <-- zod v4 import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter @@ -202,7 +202,9 @@ For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_ ```ts import * as z from 'zod' -import { JSON_SCHEMA_REGISTRY } from '@orpc/zod/zod4' +import { + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from '@orpc/zod/zod4' export const InputSchema = z.object({ name: z.string(), diff --git a/apps/content/docs/openapi/plugins/zod-smart-coercion.md b/apps/content/docs/openapi/plugins/zod-smart-coercion.md index d8a51d98c..f3b1ee305 100644 --- a/apps/content/docs/openapi/plugins/zod-smart-coercion.md +++ b/apps/content/docs/openapi/plugins/zod-smart-coercion.md @@ -42,7 +42,9 @@ deno install npm:@orpc/zod@latest ```ts import { OpenAPIHandler } from '@orpc/openapi/fetch' import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3 -import { ZodSmartCoercionPlugin } from '@orpc/zod/zod4' // <-- zod v4 +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin +} from '@orpc/zod/zod4' // <-- zod v4 const handler = new OpenAPIHandler(router, { plugins: [new ZodSmartCoercionPlugin()] diff --git a/packages/zod/src/zod4/coercer.test.ts b/packages/zod/src/zod4/coercer.test.ts index d940683a3..679cd10a8 100644 --- a/packages/zod/src/zod4/coercer.test.ts +++ b/packages/zod/src/zod4/coercer.test.ts @@ -1,5 +1,7 @@ import * as z from 'zod4' -import { ZodSmartCoercionPlugin } from './coercer' +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin, +} from './coercer' it('zodSmartCoercionPlugin ignore non-zod schemas', async () => { const plugin = new ZodSmartCoercionPlugin() diff --git a/packages/zod/src/zod4/coercer.ts b/packages/zod/src/zod4/coercer.ts index 8939637d6..3005ee421 100644 --- a/packages/zod/src/zod4/coercer.ts +++ b/packages/zod/src/zod4/coercer.ts @@ -3,7 +3,7 @@ import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server import type { $ZodArray, $ZodCatch, $ZodDefault, $ZodEnum, $ZodIntersection, $ZodLazy, $ZodLiteral, $ZodMap, $ZodNonOptional, $ZodNullable, $ZodObject, $ZodOptional, $ZodPipe, $ZodReadonly, $ZodRecord, $ZodSet, $ZodTuple, $ZodType, $ZodUnion, util } from '@zod/core' import { guard, isObject } from '@orpc/shared' -export class ZodSmartCoercionPlugin implements StandardHandlerPlugin { +export class experimental_ZodSmartCoercionPlugin implements StandardHandlerPlugin { init(options: StandardHandlerOptions): void { options.clientInterceptors ??= [] diff --git a/packages/zod/src/zod4/converter.test.ts b/packages/zod/src/zod4/converter.test.ts index e9b7c89b1..12ed06ecf 100644 --- a/packages/zod/src/zod4/converter.test.ts +++ b/packages/zod/src/zod4/converter.test.ts @@ -1,6 +1,8 @@ import * as zm from '@zod/mini' import * as z from 'zod4' -import { ZodToJsonSchemaConverter } from './converter' +import { + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter, +} from './converter' describe('zodToJsonSchemaConverter', () => { const converter = new ZodToJsonSchemaConverter() diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index 0672b528d..44de01057 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -33,9 +33,13 @@ import { intercept } from '@orpc/shared' import { globalRegistry, } from '@zod/core' -import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries' +import { + experimental_JSON_SCHEMA_INPUT_REGISTRY as JSON_SCHEMA_INPUT_REGISTRY, + experimental_JSON_SCHEMA_OUTPUT_REGISTRY as JSON_SCHEMA_OUTPUT_REGISTRY, + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from './registries' -export interface ZodToJsonSchemaOptions { +export interface experimental_ZodToJsonSchemaOptions { /** * Max depth of lazy type, if it exceeds. * @@ -73,14 +77,14 @@ export interface ZodToJsonSchemaOptions { >[] } -export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { - private readonly maxLazyDepth: Exclude - private readonly anyJsonSchema: Exclude - private readonly unsupportedJsonSchema: Exclude - private readonly undefinedJsonSchema: Exclude - private readonly interceptors: Exclude +export class experimental_ZodToJsonSchemaConverter implements ConditionalSchemaConverter { + private readonly maxLazyDepth: Exclude + private readonly anyJsonSchema: Exclude + private readonly unsupportedJsonSchema: Exclude + private readonly undefinedJsonSchema: Exclude + private readonly interceptors: Exclude - constructor(options: ZodToJsonSchemaOptions = {}) { + constructor(options: experimental_ZodToJsonSchemaOptions = {}) { this.maxLazyDepth = options.maxLazyDepth ?? 2 this.anyJsonSchema = options.anyJsonSchema ?? {} this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } diff --git a/packages/zod/src/zod4/registries.ts b/packages/zod/src/zod4/registries.ts index 12080ba1e..53e0e77eb 100644 --- a/packages/zod/src/zod4/registries.ts +++ b/packages/zod/src/zod4/registries.ts @@ -19,7 +19,7 @@ import { registry } from '@zod/core' * }) * ``` */ -export const JSON_SCHEMA_REGISTRY = registry, boolean>>() +export const experimental_JSON_SCHEMA_REGISTRY = registry, boolean>>() /** * Zod registry for customizing generated JSON schema, only useful for .input @@ -38,7 +38,7 @@ export const JSON_SCHEMA_REGISTRY = registry, boolean>>() +export const experimental_JSON_SCHEMA_INPUT_REGISTRY = registry, boolean>>() /** * Zod registry for customizing generated JSON schema, only useful for .input @@ -57,4 +57,4 @@ export const JSON_SCHEMA_INPUT_REGISTRY = registry, b * }) * ``` */ -export const JSON_SCHEMA_OUTPUT_REGISTRY = registry, boolean>>() +export const experimental_JSON_SCHEMA_OUTPUT_REGISTRY = registry, boolean>>() diff --git a/packages/zod/tests/shared.ts b/packages/zod/tests/shared.ts index 5b3c33970..aa8672eb6 100644 --- a/packages/zod/tests/shared.ts +++ b/packages/zod/tests/shared.ts @@ -1,6 +1,9 @@ import type { AnySchema } from '@orpc/contract' import type { JSONSchema } from '@orpc/openapi' -import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '../src/zod4' +import { + experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin, + experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter, +} from '../src/zod4' export interface SchemaConverterTestCase { name: string From aa2f202c93c6eeaecb02d527a53d00904079cfdb Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 15:57:04 +0700 Subject: [PATCH 11/13] fix docs --- apps/content/docs/openapi/openapi-specification.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index b1e4fef35..2387e176d 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -66,9 +66,7 @@ export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers. ::: -```ts twoslash -import { contract, router } from './shared/planet' -// ---cut--- +```ts import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter From d00b44211a73db53e94d24a05524fd565c55b155 Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 16:00:10 +0700 Subject: [PATCH 12/13] fix imports --- packages/zod/src/zod4/converter.meta.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/zod/src/zod4/converter.meta.test.ts b/packages/zod/src/zod4/converter.meta.test.ts index 4ec090e3a..8ffad9d52 100644 --- a/packages/zod/src/zod4/converter.meta.test.ts +++ b/packages/zod/src/zod4/converter.meta.test.ts @@ -1,6 +1,10 @@ import * as z from 'zod4' import { testSchemaConverter } from '../../tests/shared' -import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries' +import { + experimental_JSON_SCHEMA_INPUT_REGISTRY as JSON_SCHEMA_INPUT_REGISTRY, + experimental_JSON_SCHEMA_OUTPUT_REGISTRY as JSON_SCHEMA_OUTPUT_REGISTRY, + experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY, +} from './registries' const customSchema1 = z.string().meta({ description: 'description', From aa7ac21d9435ddc9375e813621312eccae8bdadf Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 5 May 2025 16:18:34 +0700 Subject: [PATCH 13/13] simplify --- packages/zod/src/zod4/coercer.combination.test.ts | 6 ------ packages/zod/src/zod4/coercer.native.test.ts | 8 -------- packages/zod/src/zod4/coercer.rest.test.ts | 2 -- packages/zod/src/zod4/coercer.structure.test.ts | 12 ------------ packages/zod/tests/shared.ts | 4 ++-- 5 files changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/zod/src/zod4/coercer.combination.test.ts b/packages/zod/src/zod4/coercer.combination.test.ts index 534ccab99..bf251ace3 100644 --- a/packages/zod/src/zod4/coercer.combination.test.ts +++ b/packages/zod/src/zod4/coercer.combination.test.ts @@ -8,13 +8,11 @@ testSchemaSmartCoercion([ name: 'union - 123 - un-discriminated', schema: z.union([z.boolean(), z.number()]), input: '123', - expected: '123', }, { name: 'union - object boolean - un-discriminated', schema: z.union([z.object({ a: z.boolean() }), z.object({ b: z.number() })]), input: { a: 'true' }, - expected: { a: 'true' }, }, { name: 'union - only one option', @@ -44,19 +42,16 @@ testSchemaSmartCoercion([ name: 'union - complex discriminated 2', schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), input: { a: 'type1', b: '123' }, - expected: { a: 'type1', b: '123' }, }, { name: 'union - complex discriminated 3', schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]), input: { a: { v: 'type2' }, b: '123' }, - expected: { a: { v: 'type2' }, b: '123' }, }, { name: 'union - not coerce discriminated key', schema: z.union([z.object({ a: z.literal(true), b: z.number() }), z.object({ a: z.literal(false), b: z.bigint() })]), input: { a: 'true', b: '123' }, - expected: { a: 'true', b: '123' }, }, { name: 'intersection - 123', @@ -146,7 +141,6 @@ testSchemaSmartCoercion([ name: 'lazy - invalid', schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), input: { value: { value: 'invalid' } }, - expected: { value: { value: 'invalid' } }, }, { name: 'lazy - InfiniteLazySchema', diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts index a3d95d1e0..2ac62eb8a 100644 --- a/packages/zod/src/zod4/coercer.native.test.ts +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -23,7 +23,6 @@ testSchemaSmartCoercion([ name: 'number - 12345n', schema: z.number(), input: '12345n', - expected: '12345n', }, { name: 'bigint - 12345', @@ -41,13 +40,11 @@ testSchemaSmartCoercion([ name: 'bigint - 12345n', schema: z.bigint(), input: '12345n', - expected: '12345n', }, { name: 'bigint - true', schema: z.bigint(), input: true, - expected: true, }, { name: 'boolean - t', @@ -131,7 +128,6 @@ testSchemaSmartCoercion([ name: 'literal - 199', schema: z.literal([199, '199', 200n, undefined]), input: '199', - expected: '199', }, { name: 'literal - 200', @@ -143,7 +139,6 @@ testSchemaSmartCoercion([ name: 'literal - undefined', schema: z.literal([199, '199', 200n, undefined, true]), input: undefined, - expected: undefined, }, { name: 'literal - undefined', @@ -161,18 +156,15 @@ testSchemaSmartCoercion([ name: 'nativeEnum - string', schema: z.enum(TestEnum), input: 'string', - expected: 'string', }, { name: 'nativeEnum - 123n', schema: z.enum(TestEnum), input: '123n', - expected: '123n', }, { name: 'enum - 123', schema: z.enum(['123', '456']), input: '123', - expected: '123', }, ]) diff --git a/packages/zod/src/zod4/coercer.rest.test.ts b/packages/zod/src/zod4/coercer.rest.test.ts index 135d5fb85..97a54971d 100644 --- a/packages/zod/src/zod4/coercer.rest.test.ts +++ b/packages/zod/src/zod4/coercer.rest.test.ts @@ -6,12 +6,10 @@ testSchemaSmartCoercion([ name: 'number - 123', schema: z.number().or(z.string()), input: '123', - expected: '123', }, { name: 'boolean - true', schema: z.boolean().or(z.string()), input: 'true', - expected: 'true', }, ]) diff --git a/packages/zod/src/zod4/coercer.structure.test.ts b/packages/zod/src/zod4/coercer.structure.test.ts index 5143e12da..0c47f10ec 100644 --- a/packages/zod/src/zod4/coercer.structure.test.ts +++ b/packages/zod/src/zod4/coercer.structure.test.ts @@ -12,7 +12,6 @@ testSchemaSmartCoercion([ name: 'optional array - undefined', schema: z.array(z.boolean()).optional(), input: undefined, - expected: undefined, }, { name: 'array - boolean', @@ -24,7 +23,6 @@ testSchemaSmartCoercion([ name: 'array - object', schema: z.array(z.boolean()), input: { a: 1 }, - expected: { a: 1 }, }, { name: 'tuple - undefined', @@ -36,7 +34,6 @@ testSchemaSmartCoercion([ name: 'optional tuple - undefined', schema: z.tuple([z.number(), z.boolean()]).optional(), input: undefined, - expected: undefined, }, { name: 'tuple - number, boolean', @@ -48,7 +45,6 @@ testSchemaSmartCoercion([ name: 'tuple - number', schema: z.tuple([z.number(), z.bigint()], z.boolean()), input: 123, - expected: 123, }, { name: 'tuple - without rest', @@ -66,7 +62,6 @@ testSchemaSmartCoercion([ name: 'optional set - undefined', schema: z.set(z.number()).optional(), input: undefined, - expected: undefined, }, { name: 'set - array boolean', @@ -84,7 +79,6 @@ testSchemaSmartCoercion([ name: 'set - map', schema: z.set(z.number()), input: new Map([[1, 2]]), - expected: new Map([[1, 2]]), }, { name: 'object - undefined', @@ -96,7 +90,6 @@ testSchemaSmartCoercion([ name: 'optional object - undefined', schema: z.object({ a: z.boolean() }).optional(), input: undefined, - expected: undefined, }, { name: 'object - boolean', @@ -120,7 +113,6 @@ testSchemaSmartCoercion([ name: 'object - array', schema: z.object({ a: z.boolean() }), input: [3, 2, 1], - expected: [3, 2, 1], }, { name: 'record - undefined', @@ -132,7 +124,6 @@ testSchemaSmartCoercion([ name: 'optional record - undefined', schema: z.record(z.string(), z.boolean()).optional(), input: undefined, - expected: undefined, }, { name: 'record - undefined', @@ -144,7 +135,6 @@ testSchemaSmartCoercion([ name: 'record - big int', schema: z.record(z.string(), z.boolean()), input: 123n, - expected: 123n, }, { name: 'map - undefined', @@ -156,7 +146,6 @@ testSchemaSmartCoercion([ name: 'optional map - undefined', schema: z.map(z.boolean(), z.number()).optional(), input: undefined, - expected: undefined, }, { name: 'map - array', @@ -174,6 +163,5 @@ testSchemaSmartCoercion([ name: 'map - invalid array', schema: z.map(z.boolean(), z.number()), input: [1, 2, 3], - expected: [1, 2, 3], }, ]) diff --git a/packages/zod/tests/shared.ts b/packages/zod/tests/shared.ts index aa8672eb6..a059b7ba8 100644 --- a/packages/zod/tests/shared.ts +++ b/packages/zod/tests/shared.ts @@ -28,7 +28,7 @@ export interface SchemaSmartCoercionTestCase { name: string schema: AnySchema input: unknown - expected: unknown + expected?: unknown } export function testSchemaSmartCoercion(cases: SchemaSmartCoercionTestCase[]) { @@ -54,7 +54,7 @@ export function testSchemaSmartCoercion(cases: SchemaSmartCoercionTestCase[]) { return coerced } - it.each(cases)('$name', ({ schema, input, expected }) => { + it.each(cases)('$name', ({ schema, input, expected = input }) => { expect(coerce(schema, input)).toEqual(expected) }) }