From 65361160ed62606a4c3ed71e0096a9ab6cc14cbb Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 5 Mar 2025 19:28:38 +0700 Subject: [PATCH 01/11] wip --- packages/zod/src/custom-json-schema.test-d.ts | 62 +++++ packages/zod/src/custom-json-schema.test.ts | 34 +++ packages/zod/src/custom-json-schema.ts | 57 +++++ packages/zod/src/index.ts | 23 +- packages/zod/src/schemas.ts | 241 ------------------ packages/zod/src/schemas/base.ts | 55 ++++ packages/zod/src/schemas/blob.test.ts | 11 + packages/zod/src/schemas/blob.ts | 20 ++ packages/zod/src/schemas/file.test.ts | 32 +++ packages/zod/src/schemas/file.ts | 47 ++++ packages/zod/src/schemas/regexp.test.ts | 10 + packages/zod/src/schemas/regexp.ts | 20 ++ packages/zod/src/schemas/url.test.ts | 10 + packages/zod/src/schemas/url.ts | 19 ++ 14 files changed, 397 insertions(+), 244 deletions(-) create mode 100644 packages/zod/src/custom-json-schema.test-d.ts create mode 100644 packages/zod/src/custom-json-schema.test.ts create mode 100644 packages/zod/src/custom-json-schema.ts delete mode 100644 packages/zod/src/schemas.ts create mode 100644 packages/zod/src/schemas/base.ts create mode 100644 packages/zod/src/schemas/blob.test.ts create mode 100644 packages/zod/src/schemas/blob.ts create mode 100644 packages/zod/src/schemas/file.test.ts create mode 100644 packages/zod/src/schemas/file.ts create mode 100644 packages/zod/src/schemas/regexp.test.ts create mode 100644 packages/zod/src/schemas/regexp.ts create mode 100644 packages/zod/src/schemas/url.test.ts create mode 100644 packages/zod/src/schemas/url.ts diff --git a/packages/zod/src/custom-json-schema.test-d.ts b/packages/zod/src/custom-json-schema.test-d.ts new file mode 100644 index 000000000..7d25bf967 --- /dev/null +++ b/packages/zod/src/custom-json-schema.test-d.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import { customJsonSchema } from './custom-json-schema' + +describe('customJsonSchema', () => { + it('both strategy', () => { + customJsonSchema(z.string(), { + examples: ['string'], + }) + customJsonSchema(z.string(), { + // @ts-expect-error --- must be string + examples: [123], + }) + + customJsonSchema(z.string().transform(v => Number(v)), { + examples: [{} as never], + }) + customJsonSchema(z.string().transform(v => Number(v)), { + // @ts-expect-error --- must be never + examples: ['string'], + }) + customJsonSchema(z.string().transform(v => Number(v)), { + // @ts-expect-error --- must be never + examples: [123], + }) + }) + + it('input strategy', () => { + customJsonSchema(z.string(), { + examples: ['string'], + }, 'input') + customJsonSchema(z.string(), { + // @ts-expect-error --- must be string + examples: [123], + }, 'input') + + customJsonSchema(z.string().transform(v => Number(v)), { + examples: ['string'], + }, 'input') + customJsonSchema(z.string().transform(v => Number(v)), { + // @ts-expect-error --- must be string + examples: [123], + }, 'input') + }) + + it('output strategy', () => { + customJsonSchema(z.string(), { + examples: ['string'], + }, 'output') + customJsonSchema(z.string(), { + // @ts-expect-error --- must be string + examples: [123], + }, 'output') + + customJsonSchema(z.string().transform(v => Number(v)), { + examples: [123], + }, 'output') + customJsonSchema(z.string().transform(v => Number(v)), { + // @ts-expect-error --- must be number + examples: ['string'], + }, 'output') + }) +}) diff --git a/packages/zod/src/custom-json-schema.test.ts b/packages/zod/src/custom-json-schema.test.ts new file mode 100644 index 000000000..445883c8e --- /dev/null +++ b/packages/zod/src/custom-json-schema.test.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { customJsonSchema, getCustomJsonSchema } from './custom-json-schema' + +describe('custom json schema', () => { + it('both strategy', () => { + const schema = customJsonSchema(z.string(), { $comment: '__COMMENT__' }, 'both') + + expect(() => schema.parse(123)).toThrow('Expected string, received number') + + expect(getCustomJsonSchema(schema._def, 'both')).toEqual({ $comment: '__COMMENT__' }) + expect(getCustomJsonSchema(schema._def, 'input')).toEqual({ $comment: '__COMMENT__' }) + expect(getCustomJsonSchema(schema._def, 'output')).toEqual({ $comment: '__COMMENT__' }) + }) + + it('input strategy', () => { + const schema = customJsonSchema(z.string(), { $comment: '__COMMENT__' }, 'input') + + expect(() => schema.parse(123)).toThrow('Expected string, received number') + + expect(getCustomJsonSchema(schema._def, 'both')).toEqual(undefined) + expect(getCustomJsonSchema(schema._def, 'input')).toEqual({ $comment: '__COMMENT__' }) + expect(getCustomJsonSchema(schema._def, 'output')).toEqual(undefined) + }) + + it('output strategy', () => { + const schema = customJsonSchema(z.string(), { $comment: '__COMMENT__' }, 'output') + + expect(() => schema.parse(123)).toThrow('Expected string, received number') + + expect(getCustomJsonSchema(schema._def, 'both')).toEqual(undefined) + expect(getCustomJsonSchema(schema._def, 'input')).toEqual(undefined) + expect(getCustomJsonSchema(schema._def, 'output')).toEqual({ $comment: '__COMMENT__' }) + }) +}) diff --git a/packages/zod/src/custom-json-schema.ts b/packages/zod/src/custom-json-schema.ts new file mode 100644 index 000000000..419489b89 --- /dev/null +++ b/packages/zod/src/custom-json-schema.ts @@ -0,0 +1,57 @@ +import type { JSONSchema } from 'json-schema-typed/draft-2020-12' +import type { input, output, ZodTypeAny, ZodTypeDef } from 'zod' + +const CUSTOM_JSON_SCHEMA_SYMBOL = Symbol('ORPC_CUSTOM_JSON_SCHEMA') +const CUSTOM_JSON_SCHEMA_INPUT_SYMBOL = Symbol('ORPC_CUSTOM_JSON_SCHEMA_INPUT') +const CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL = Symbol('ORPC_CUSTOM_JSON_SCHEMA_OUTPUT') + +export function getCustomJsonSchema( + def: ZodTypeDef, + strategy: 'input' | 'output' | 'both', +): Exclude | undefined { + if (strategy === 'input' && CUSTOM_JSON_SCHEMA_INPUT_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_INPUT_SYMBOL] as Exclude + } + + if (strategy === 'output' && CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL] as Exclude + } + + if (CUSTOM_JSON_SCHEMA_SYMBOL in def) { + return def[CUSTOM_JSON_SCHEMA_SYMBOL] as Exclude + } + + return undefined +} + +export function customJsonSchema< + T extends ZodTypeAny, + TStrategy extends 'input' | 'output' | 'both' = 'both', +>( + schema: T, + custom: Exclude< + JSONSchema< + TStrategy extends 'input' + ? input + : TStrategy extends 'output' + ? output + : input & output + >, + boolean + >, + strategy?: TStrategy, +): T { + const SYMBOL = strategy === 'input' + ? CUSTOM_JSON_SCHEMA_INPUT_SYMBOL + : strategy === 'output' + ? CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL + : CUSTOM_JSON_SCHEMA_SYMBOL + + const This = (schema as any).constructor + const newSchema = new This({ + ...schema._def, + [SYMBOL]: custom, + }) + + return newSchema +} diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 3c56341d7..cf4004cce 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,3 +1,20 @@ -export * from './coercer' -export * from './converter' -export * from './schemas' +import { customJsonSchema } from './custom-json-schema' +import { blob } from './schemas/blob' +import { file } from './schemas/file' +import { regexp } from './schemas/regexp' +import { url } from './schemas/url' + +export * from './custom-json-schema' +export * from './schemas/base' +export * from './schemas/blob' +export * from './schemas/file' +export * from './schemas/regexp' +export * from './schemas/url' + +export const oz = { + file, + blob, + url, + regexp, + openapi: customJsonSchema, +} diff --git a/packages/zod/src/schemas.ts b/packages/zod/src/schemas.ts deleted file mode 100644 index a9ec8da9b..000000000 --- a/packages/zod/src/schemas.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { JSONSchema } from 'json-schema-typed/draft-2020-12' - -import wcmatch from 'wildcard-match' -import { - custom, - type CustomErrorParams, - type input, - type output, - type ZodEffects, - type ZodType, - type ZodTypeAny, - type ZodTypeDef, -} from 'zod' - -export type CustomZodType = 'File' | 'Blob' | 'Invalid Date' | 'RegExp' | 'URL' - -const customZodTypeSymbol = Symbol('customZodTypeSymbol') - -const customZodFileMimeTypeSymbol = Symbol('customZodFileMimeTypeSymbol') - -const CUSTOM_JSON_SCHEMA_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA') -const CUSTOM_JSON_SCHEMA_INPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_INPUT') -const CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL = Symbol('CUSTOM_JSON_SCHEMA_OUTPUT') - -type CustomParams = CustomErrorParams & { - fatal?: boolean -} - -export function getCustomZodType(def: ZodTypeDef): CustomZodType | undefined { - return customZodTypeSymbol in def - ? (def[customZodTypeSymbol] as CustomZodType) - : undefined -} - -export function getCustomZodFileMimeType(def: ZodTypeDef): string | undefined { - return customZodFileMimeTypeSymbol in def - ? (def[customZodFileMimeTypeSymbol] as string) - : undefined -} - -export function getCustomJSONSchema( - def: ZodTypeDef, - options?: { mode?: 'input' | 'output' }, -): Exclude | undefined { - if (options?.mode === 'input' && CUSTOM_JSON_SCHEMA_INPUT_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_INPUT_SYMBOL] as Exclude - } - - if (options?.mode === 'output' && CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL] as Exclude - } - - if (CUSTOM_JSON_SCHEMA_SYMBOL in def) { - return def[CUSTOM_JSON_SCHEMA_SYMBOL] as Exclude - } - - return undefined -} - -function composeParams(options: { - params?: string | CustomParams | ((input: T) => CustomParams) - defaultMessage?: string | ((input: T) => string) -}): (input: T) => CustomParams { - return (val) => { - const defaultMessage - = typeof options.defaultMessage === 'function' - ? options.defaultMessage(val) - : options.defaultMessage - - if (!options.params) { - return { - message: defaultMessage, - } - } - - if (typeof options.params === 'function') { - return { - message: defaultMessage, - ...options.params(val), - } - } - - if (typeof options.params === 'object') { - return { - message: defaultMessage, - ...options.params, - } - } - - return { - message: options.params, - } - } -} - -export function file( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType, ZodTypeDef, InstanceType> & { - type( - mimeType: string, - params?: string | CustomParams | ((input: unknown) => CustomParams), - ): ZodEffects< - ZodType, ZodTypeDef, InstanceType>, - InstanceType, - InstanceType - > -} { - const schema = custom>( - val => val instanceof File, - composeParams({ params, defaultMessage: 'Input is not a file' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'File' satisfies CustomZodType, - }) - - return Object.assign(schema, { - type: ( - mimeType: string, - params?: string | CustomParams | ((input: unknown) => CustomParams), - ) => { - const isMatch = wcmatch(mimeType) - - const refinedSchema = schema.refine( - val => isMatch(val.type.split(';')[0]!), - composeParams>({ - params, - defaultMessage: val => - `Expected a file of type ${mimeType} but got a file of type ${val.type || 'unknown'}`, - }), - ) - - Object.assign(refinedSchema._def, { - [customZodTypeSymbol]: 'File' satisfies CustomZodType, - [customZodFileMimeTypeSymbol]: mimeType, - }) - - return refinedSchema - }, - }) -} - -export function blob( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType, ZodTypeDef, InstanceType> { - const schema = custom>( - val => val instanceof Blob, - composeParams({ params, defaultMessage: 'Input is not a blob' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'Blob' satisfies CustomZodType, - }) - - return schema -} - -export function invalidDate( - params?: string | CustomParams | ((input: unknown) => CustomParams), -): ZodType { - const schema = custom( - val => val instanceof Date && Number.isNaN(val.getTime()), - composeParams({ params, defaultMessage: 'Input is not an invalid date' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'Invalid Date' satisfies CustomZodType, - }) - - return schema -} - -export function regexp( - options?: CustomParams, -): ZodType { - const schema = custom( - val => val instanceof RegExp, - composeParams({ params: options, defaultMessage: 'Input is not a regexp' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'RegExp' satisfies CustomZodType, - }) - - return schema -} - -export function url(options?: CustomParams): ZodType { - const schema = custom( - val => val instanceof URL, - composeParams({ params: options, defaultMessage: 'Input is not a URL' }), - ) - - Object.assign(schema._def, { - [customZodTypeSymbol]: 'URL' satisfies CustomZodType, - }) - - return schema -} - -export function openapi< - T extends ZodTypeAny, - TMode extends 'input' | 'output' | 'both' = 'both', ->( - schema: T, - custom: Exclude< - JSONSchema< - TMode extends 'input' - ? input - : TMode extends 'output' - ? output - : input & output - >, - boolean - >, - options?: { mode: TMode }, -): ReturnType { - const newSchema = schema.refine(() => true) as ReturnType - - const SYMBOL - = options?.mode === 'input' - ? CUSTOM_JSON_SCHEMA_INPUT_SYMBOL - : options?.mode === 'output' - ? CUSTOM_JSON_SCHEMA_OUTPUT_SYMBOL - : CUSTOM_JSON_SCHEMA_SYMBOL - - Object.assign(newSchema._def, { - [SYMBOL]: custom, - }) - - return newSchema -} - -export const oz = { - openapi, - file, - blob, - invalidDate, - regexp, - url, -} diff --git a/packages/zod/src/schemas/base.ts b/packages/zod/src/schemas/base.ts new file mode 100644 index 000000000..d95d0a38c --- /dev/null +++ b/packages/zod/src/schemas/base.ts @@ -0,0 +1,55 @@ +import type { CustomErrorParams, ZodTypeDef } from 'zod' + +export type CustomParams = CustomErrorParams & { + fatal?: boolean +} + +const CUSTOM_ZOD_DEF_SYMBOL = Symbol('ORPC_CUSTOM_ZOD_DEF') + +export type CustomZodDef = { + type: 'blob' | 'regexp' | 'url' +} | { + type: 'file' + mimeType?: string +} + +export function setCustomZodDef(def: T, custom: CustomZodDef): void { + Object.assign(def, { [CUSTOM_ZOD_DEF_SYMBOL]: custom }) +} + +export function getCustomZodDef(def: ZodTypeDef): CustomZodDef | undefined { + return (def as any)[CUSTOM_ZOD_DEF_SYMBOL] as CustomZodDef | undefined +} + +export function composeParams( + defaultMessage: (input: T) => string, + params: undefined | string | CustomParams | ((input: T) => CustomParams), +): (input: T) => CustomParams { + return (val) => { + const message = defaultMessage(val) + + if (!params) { + return { + message, + } + } + + if (typeof params === 'function') { + return { + message, + ...params(val), + } + } + + if (typeof params === 'object') { + return { + message, + ...params, + } + } + + return { + message: params, + } + } +} diff --git a/packages/zod/src/schemas/blob.test.ts b/packages/zod/src/schemas/blob.test.ts new file mode 100644 index 000000000..a7cae058e --- /dev/null +++ b/packages/zod/src/schemas/blob.test.ts @@ -0,0 +1,11 @@ +import { getCustomZodDef } from './base' +import { blob } from './blob' + +it('blob', () => { + expect(blob().parse(new Blob([]))).toBeInstanceOf(Blob) + expect(blob().parse(new File([], ''))).toBeInstanceOf(File) + expect(() => blob().parse({})).toThrow('Input is not a blob') + expect(() => blob('__INVALID__').parse({})).toThrow('__INVALID__') + + expect(getCustomZodDef(blob()._def)).toEqual({ type: 'blob' }) +}) diff --git a/packages/zod/src/schemas/blob.ts b/packages/zod/src/schemas/blob.ts new file mode 100644 index 000000000..7e2768ba5 --- /dev/null +++ b/packages/zod/src/schemas/blob.ts @@ -0,0 +1,20 @@ +import type { ZodType, ZodTypeDef } from 'zod' +import type { CustomParams } from './base' +import { custom } from 'zod' +import { composeParams, setCustomZodDef } from './base' + +export function blob( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType { + const schema = custom( + val => val instanceof Blob, + composeParams( + () => 'Input is not a blob', + params, + ), + ) + + setCustomZodDef(schema._def, { type: 'blob' }) + + return schema +} diff --git a/packages/zod/src/schemas/file.test.ts b/packages/zod/src/schemas/file.test.ts new file mode 100644 index 000000000..54d2c07de --- /dev/null +++ b/packages/zod/src/schemas/file.test.ts @@ -0,0 +1,32 @@ +import { getCustomZodDef } from './base' +import { file } from './file' + +describe('file', () => { + it('works', () => { + expect(file().parse(new File([], ''))).toBeInstanceOf(File) + expect(() => file().parse({})).toThrow('Input is not a file') + expect(() => file({ message: '__INVALID__' }).parse({})).toThrow('__INVALID__') + + expect(getCustomZodDef(file()._def)).toEqual({ type: 'file' }) + }) + + it('mine type', () => { + const schema = file().type('image/*') + expect(schema.parse(new File([], 'images.png', { type: 'image/png' }))).toBeInstanceOf(File) + + expect(() => schema.parse({})).toThrow('Input is not a file') + expect( + () => schema.parse(new File([], 'file.pdf', { type: 'application/pdf' })), + ).toThrow('Expected a file of type image/* but got a file of type application/pdf') + + expect( + () => schema.parse(new File([], 'file.pdf')), + ).toThrow('Expected a file of type image/* but got a file of type unknown') + + expect( + () => file().type('image/*', '__INVALID__').parse(new File([], 'file.pdf', { type: 'application/pdf' })), + ).toThrow('__INVALID__') + + expect(getCustomZodDef(schema._def)).toEqual({ type: 'file', mimeType: 'image/*' }) + }) +}) diff --git a/packages/zod/src/schemas/file.ts b/packages/zod/src/schemas/file.ts new file mode 100644 index 000000000..6aeea76d0 --- /dev/null +++ b/packages/zod/src/schemas/file.ts @@ -0,0 +1,47 @@ +import wcmatch from 'wildcard-match' +import { custom, type ZodEffects, type ZodType, type ZodTypeDef } from 'zod' +import { composeParams, type CustomParams, setCustomZodDef } from './base' + +export function file( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType & { + type( + mimeType: string, + params?: string | CustomParams | ((input: unknown) => CustomParams), + ): ZodEffects< + ZodType, + File, + File + > +} { + const schema = custom( + val => val instanceof File, + composeParams( + () => 'Input is not a file', + params, + ), + ) + + setCustomZodDef(schema._def, { type: 'file' }) + + return Object.assign(schema, { + type: ( + mimeType: string, + params?: string | CustomParams | ((input: unknown) => CustomParams), + ) => { + const isMatch = wcmatch(mimeType) + + const refinedSchema = schema.refine( + val => isMatch(val.type.split(';')[0]!), + composeParams( + val => `Expected a file of type ${mimeType} but got a file of type ${val.type || 'unknown'}`, + params, + ), + ) + + setCustomZodDef(refinedSchema._def, { type: 'file', mimeType }) + + return refinedSchema + }, + }) +} diff --git a/packages/zod/src/schemas/regexp.test.ts b/packages/zod/src/schemas/regexp.test.ts new file mode 100644 index 000000000..f3f7adc07 --- /dev/null +++ b/packages/zod/src/schemas/regexp.test.ts @@ -0,0 +1,10 @@ +import { getCustomZodDef } from './base' +import { regexp } from './regexp' + +it('regexp', () => { + expect(regexp().parse(/d/)).toBeInstanceOf(RegExp) + expect(() => regexp().parse({})).toThrow('Input is not a regexp') + expect(() => regexp(() => ({ message: '__INVALID__' })).parse({})).toThrow('__INVALID__') + + expect(getCustomZodDef(regexp()._def)).toEqual({ type: 'regexp' }) +}) diff --git a/packages/zod/src/schemas/regexp.ts b/packages/zod/src/schemas/regexp.ts new file mode 100644 index 000000000..7f13c5139 --- /dev/null +++ b/packages/zod/src/schemas/regexp.ts @@ -0,0 +1,20 @@ +import type { ZodType, ZodTypeDef } from 'zod' +import type { CustomParams } from './base' +import { custom } from 'zod' +import { composeParams, setCustomZodDef } from './base' + +export function regexp( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType { + const schema = custom( + val => val instanceof RegExp, + composeParams( + () => 'Input is not a regexp', + params, + ), + ) + + setCustomZodDef(schema._def, { type: 'regexp' }) + + return schema +} diff --git a/packages/zod/src/schemas/url.test.ts b/packages/zod/src/schemas/url.test.ts new file mode 100644 index 000000000..71734e170 --- /dev/null +++ b/packages/zod/src/schemas/url.test.ts @@ -0,0 +1,10 @@ +import { getCustomZodDef } from './base' +import { url } from './url' + +it('url', () => { + expect(url().parse(new URL('https://example.com'))).toBeInstanceOf(URL) + expect(() => url().parse({})).toThrow('Input is not a URL') + expect(() => url('__INVALID__').parse({})).toThrow('__INVALID__') + + expect(getCustomZodDef(url()._def)).toEqual({ type: 'url' }) +}) diff --git a/packages/zod/src/schemas/url.ts b/packages/zod/src/schemas/url.ts new file mode 100644 index 000000000..dbac88050 --- /dev/null +++ b/packages/zod/src/schemas/url.ts @@ -0,0 +1,19 @@ +import type { ZodType, ZodTypeDef } from 'zod' +import { custom } from 'zod' +import { composeParams, type CustomParams, setCustomZodDef } from './base' + +export function url( + params?: string | CustomParams | ((input: unknown) => CustomParams), +): ZodType { + const schema = custom( + val => val instanceof URL, + composeParams( + () => 'Input is not a URL', + params, + ), + ) + + setCustomZodDef(schema._def, { type: 'url' }) + + return schema +} From 80e28f5ca9d0fb3d6d697c986ca0bd8062adb0c6 Mon Sep 17 00:00:00 2001 From: unnoq Date: Wed, 5 Mar 2025 21:13:13 +0700 Subject: [PATCH 02/11] wip - converter --- packages/zod/src/converter.test.ts | 485 -------------- packages/zod/src/converter.ts | 989 +++++++++++++---------------- packages/zod/src/index.ts | 1 + 3 files changed, 426 insertions(+), 1049 deletions(-) delete mode 100644 packages/zod/src/converter.test.ts diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts deleted file mode 100644 index b1e661489..000000000 --- a/packages/zod/src/converter.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { oz } from '@orpc/zod' -import { Format } from 'json-schema-typed/draft-2020-12' -import { describe, expect, it } from 'vitest' -import { z } from 'zod' -import { zodToJsonSchema } from './converter' - -describe('primitive types', () => { - it('should convert string schema', () => { - const schema = z.string() - expect(zodToJsonSchema(schema)).toEqual({ type: 'string' }) - }) - - it('should convert string schema with constraints', () => { - const schema = z - .string() - .min(5) - .max(10) - .email() - .regex(/^[a-z]+$/) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'string', - minLength: 5, - maxLength: 10, - format: Format.Email, - pattern: '^[a-z]+$', - }) - }) - - it('should convert number schema', () => { - const schema = z.number() - expect(zodToJsonSchema(schema)).toEqual({ type: 'number' }) - }) - - it('should convert number schema with constraints', () => { - const schema = z.number().int().min(0).max(100).multipleOf(5) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'integer', - minimum: 0, - maximum: 100, - multipleOf: 5, - }) - }) - - it('should convert boolean schema', () => { - const schema = z.boolean() - expect(zodToJsonSchema(schema)).toEqual({ type: 'boolean' }) - }) - - it('should convert null schema', () => { - const schema = z.null() - expect(zodToJsonSchema(schema)).toEqual({ type: 'null' }) - }) - - it('should convert undefined schema', () => { - const schema = z.undefined() - expect(zodToJsonSchema(schema)).toEqual({ const: 'undefined' }) - }) - - it('should convert literal schema', () => { - const schema = z.literal('hello') - expect(zodToJsonSchema(schema)).toEqual({ const: 'hello' }) - }) -}) - -describe('array types', () => { - it('should convert array schema', () => { - const schema = z.array(z.string()) - expect(zodToJsonSchema(schema)).toEqual({ - type: 'array', - items: { type: 'string' }, - }) - }) - - it('should convert array schema with length constraints', () => { - const schema = z.array(z.string()).min(1).max(5) - expect(zodToJsonSchema(schema)).toEqual({ - type: 'array', - items: { - type: 'string', - }, - minItems: 1, - maxItems: 5, - }) - }) - - it('should convert tuple schema', () => { - const schema = z.tuple([z.string(), z.number()]) - expect(zodToJsonSchema(schema)).toEqual({ - type: 'array', - prefixItems: [{ type: 'string' }, { type: 'number' }], - }) - }) -}) - -describe('object types', () => { - it('should convert object schema', () => { - const schema = z.object({ - name: z.string(), - age: z.number(), - email: z.string().email(), - }) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - email: { type: 'string', format: Format.Email }, - }, - required: ['name', 'age', 'email'], - }) - }) - - it('should handle optional properties', () => { - const schema = z.object({ - name: z.string(), - age: z.number().optional(), - }) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'number' }, - }, - required: ['name'], - }) - }) - - it('should convert record schema', () => { - const schema = z.record(z.string(), z.number()) - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - additionalProperties: { type: 'number' }, - }) - }) -}) - -describe('union and intersection types', () => { - it('should convert union schema', () => { - const schema = z.union([z.string(), z.number()]) - expect(zodToJsonSchema(schema)).toEqual({ - anyOf: [{ type: 'string' }, { type: 'number' }], - }) - }) - - it('should convert discriminated union schema', () => { - const schema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('a'), value: z.string() }), - z.object({ type: z.literal('b'), value: z.number() }), - ]) - - expect(zodToJsonSchema(schema)).toEqual({ - anyOf: [ - { - type: 'object', - properties: { - type: { const: 'a' }, - value: { type: 'string' }, - }, - required: ['type', 'value'], - }, - { - type: 'object', - properties: { - type: { const: 'b' }, - value: { type: 'number' }, - }, - required: ['type', 'value'], - }, - ], - }) - }) - - it('should convert intersection schema', () => { - const schema = z.intersection( - z.object({ name: z.string() }), - z.object({ age: z.number() }), - ) - - expect(zodToJsonSchema(schema)).toEqual({ - allOf: [ - { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - { - type: 'object', - properties: { age: { type: 'number' } }, - required: ['age'], - }, - ], - }) - }) -}) - -describe('modifiers', () => { - it('should convert optional schema', () => { - const schema = z.string().optional() - expect(zodToJsonSchema(schema)).toEqual({ - anyOf: [{ const: 'undefined' }, { type: 'string' }], - }) - }) - - it('should convert nullable schema', () => { - const schema = z.string().nullable() - expect(zodToJsonSchema(schema)).toEqual({ - anyOf: [{ type: 'null' }, { type: 'string' }], - }) - }) - - it('should convert readonly schema', () => { - const schema = z.string().readonly() - expect(zodToJsonSchema(schema)).toEqual({ type: 'string' }) - }) -}) - -describe('special types', () => { - it('should convert date schema', () => { - const schema = z.date() - expect(zodToJsonSchema(schema)).toEqual({ - type: 'string', - format: Format.Date, - }) - }) - - it('should convert enum schema', () => { - const schema = z.enum(['A', 'B', 'C']) - expect(zodToJsonSchema(schema)).toEqual({ - enum: ['A', 'B', 'C'], - }) - }) - - it('should convert native enum schema', () => { - enum TestEnum { - A = 'A', - B = 'B', - } - const schema = z.nativeEnum(TestEnum) - expect(zodToJsonSchema(schema)).toEqual({ - enum: ['A', 'B'], - }) - }) -}) - -describe('transform and effects', () => { - it('should handle transform effects based on mode', () => { - const schema = z.string().transform(val => val.length) - - expect(zodToJsonSchema(schema, { mode: 'input' })).toEqual({ - type: 'string', - }) - - expect(zodToJsonSchema(schema, { mode: 'output' })).toEqual({}) - }) -}) - -describe('lazy types', () => { - it('should handle lazy types with depth limit', () => { - type Tree = { - value: string - children?: Tree[] - } - - const treeSchema: z.ZodType = z.lazy(() => - z.object({ - value: z.string(), - children: z.array(treeSchema).optional(), - }), - ) - - const tree1 = { - type: 'object', - properties: { - value: { type: 'string' }, - children: { - type: 'array', - items: {}, - }, - }, - required: ['value'], - } - const tree2 = { - type: 'object', - properties: { - value: { type: 'string' }, - children: { - type: 'array', - items: tree1, - }, - }, - required: ['value'], - } - - expect(zodToJsonSchema(treeSchema, { maxLazyDepth: 2 })).toEqual({ - type: 'object', - properties: { - value: { type: 'string' }, - children: { - type: 'array', - items: tree2, - }, - }, - required: ['value'], - }) - }) -}) - -describe('with custom json schema', () => { - const schema = oz.openapi(z.object({}), { - examples: [{ a: '23' }], - }) - - const schema2 = oz.openapi( - z.object({}), - { - examples: [{ a: '23' }, { b: '23' }], - }, - { mode: 'input' }, - ) - - const schema3 = oz.openapi( - z.object({}), - { - examples: [{ a: '23' }, { b: '23' }], - }, - { mode: 'output' }, - ) - - it('works with input mode', () => { - expect(zodToJsonSchema(schema, { mode: 'input' })).toEqual({ - type: 'object', - examples: [{ a: '23' }], - }) - - expect(zodToJsonSchema(schema2, { mode: 'input' })).toEqual({ - type: 'object', - examples: [{ a: '23' }, { b: '23' }], - }) - - expect(zodToJsonSchema(schema3, { mode: 'input' })).toEqual({ - type: 'object', - }) - }) - - it('works with output mode', () => { - expect(zodToJsonSchema(schema, { mode: 'output' })).toEqual({ - type: 'object', - examples: [{ a: '23' }], - }) - - expect(zodToJsonSchema(schema2, { mode: 'output' })).toEqual({ - type: 'object', - }) - - expect(zodToJsonSchema(schema3, { mode: 'output' })).toEqual({ - type: 'object', - examples: [{ a: '23' }, { b: '23' }], - }) - }) - - it('works on complex schema', () => { - const schema = z.object({ - nested: z.object({ - union: oz.openapi( - z.union([ - oz.openapi(z.string(), { - $comment: 'comment for string', - }), - z.object({ - url: oz.openapi(oz.url(), { - $comment: 'comment for url', - }), - }), - ]), - { - $comment: 'comment for nested', - }, - ), - }), - }) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - nested: { - type: 'object', - properties: { - union: { - $comment: 'comment for nested', - anyOf: [ - { - type: 'string', - $comment: 'comment for string', - }, - { - type: 'object', - properties: { - url: { - type: 'string', - format: Format.URI, - $comment: 'comment for url', - }, - }, - required: ['url'], - }, - ], - }, - }, - required: ['union'], - }, - }, - required: ['nested'], - }) - }) -}) - -describe('zod description', () => { - it('should include descriptions for basic and nested properties', () => { - const schema = z.object({ - name: z.string().describe('name description'), - - nested: z.object({ - name: z.string().describe('nested name description'), - }), - }) - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - properties: { - name: { - type: 'string', - description: 'name description', - }, - nested: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'nested name description', - }, - }, - required: ['name'], - }, - }, - required: ['name', 'nested'], - }) - }) - - it('should include outer and inner descriptions', () => { - const schema = z.object({ - name: z.string().describe('name description'), - - nested: z.object({ - name: z.string().describe('nested name description'), - }).describe('inner object description'), - }).describe('outer object description') - - expect(zodToJsonSchema(schema)).toEqual({ - type: 'object', - description: 'outer object description', - properties: { - name: { - type: 'string', - description: 'name description', - }, - nested: { - type: 'object', - description: 'inner object description', - properties: { - name: { - type: 'string', - description: 'nested name description', - }, - }, - required: ['name'], - }, - }, - required: ['name', 'nested'], - }) - }) -}) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 978a58a45..9e9c3b9a9 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -1,79 +1,11 @@ import type { Schema } from '@orpc/contract' -import type { JSONSchema, SchemaConverter, SchemaConvertOptions } from '@orpc/openapi' -import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { JSONSchema } from 'json-schema-typed/draft-2020-12' +import type { EnumLike, KeySchema, ZodAny, ZodArray, ZodBranded, ZodCatch, ZodDefault, ZodDiscriminatedUnion, ZodEffects, ZodEnum, ZodIntersection, ZodLazy, ZodLiteral, ZodMap, ZodNativeEnum, ZodNullable, ZodNumber, ZodObject, ZodOptional, ZodPipeline, ZodRawShape, ZodReadonly, ZodRecord, ZodSet, ZodString, ZodTuple, ZodTypeAny, ZodTypeDef, ZodUnion, ZodUnionOptions } from 'zod' import { JSONSchemaFormat } from '@orpc/openapi' import escapeStringRegexp from 'escape-string-regexp' -import { - type EnumLike, - type KeySchema, - type ZodAny, - type ZodArray, - type ZodBranded, - type ZodCatch, - type ZodDefault, - type ZodDiscriminatedUnion, - type ZodEffects, - type ZodEnum, - ZodFirstPartyTypeKind, - type ZodIntersection, - type ZodLazy, - type ZodLiteral, - type ZodMap, - type ZodNativeEnum, - type ZodNullable, - type ZodNumber, - type ZodObject, - type ZodOptional, - type ZodPipeline, - type ZodRawShape, - type ZodReadonly, - type ZodRecord, - type ZodSet, - type ZodString, - type ZodTuple, - type ZodTypeAny, - type ZodUnion, - type ZodUnionOptions, -} from 'zod' -import { - getCustomJSONSchema, - getCustomZodFileMimeType, - getCustomZodType, -} from './schemas' - -export const NON_LOGIC_KEYWORDS = [ - // Core Documentation Keywords - '$anchor', - '$comment', - '$defs', - '$id', - 'title', - 'description', - - // Value Keywords - 'default', - 'deprecated', - 'examples', - - // Metadata Keywords - '$schema', - 'definitions', // Legacy, but still used - 'readOnly', - 'writeOnly', - - // Display and UI Hints - 'contentMediaType', - 'contentEncoding', - 'format', - - // Custom Extensions - '$vocabulary', - '$dynamicAnchor', - '$dynamicRef', -] satisfies (typeof JSONSchema.keywords)[number][] - -export const UNSUPPORTED_JSON_SCHEMA = { not: {} } -export const UNDEFINED_JSON_SCHEMA = { const: 'undefined' } +import { ZodFirstPartyTypeKind } from 'zod' +import { getCustomJsonSchema } from './custom-json-schema' +import { getCustomZodDef } from './schemas/base' export interface ZodToJsonSchemaOptions { /** @@ -81,619 +13,548 @@ export interface ZodToJsonSchemaOptions { * * Used `{}` when reach max depth * - * @default 5 + * @default 3 */ maxLazyDepth?: number - - /** - * The length used to track the depth of lazy type - * - * @internal - */ - lazyDepth?: number - - /** - * The expected json schema for input or output zod schema - * - * @default input - */ - mode?: 'input' | 'output' - - /** - * Track if current level schema is handled custom json schema to prevent recursive - * - * @internal - */ - isHandledCustomJSONSchema?: boolean - - /** - * Track if current level schema is handled zod description to prevent recursive - * - * @internal - */ - isHandledZodDescription?: boolean } -export function zodToJsonSchema( - schema: StandardSchemaV1, - options?: ZodToJsonSchemaOptions, -): Exclude { - if (schema['~standard'].vendor !== 'zod') { - console.warn(`Generate JSON schema not support ${schema['~standard'].vendor} yet`) - return {} - } - - const schema__ = schema as ZodTypeAny +export class ZodToJsonSchemaConverter { + private readonly maxLazyDepth: Exclude - if (!options?.isHandledZodDescription && 'description' in schema__._def) { - const json = zodToJsonSchema(schema__, { - ...options, - isHandledZodDescription: true, - }) - - return { - description: schema__._def.description, - ...json, - } + constructor(options: ZodToJsonSchemaOptions = {}) { + this.maxLazyDepth = options.maxLazyDepth ?? 3 } - if (!options?.isHandledCustomJSONSchema) { - const customJSONSchema = getCustomJSONSchema(schema__._def, options) - - if (customJSONSchema) { - const json = zodToJsonSchema(schema__, { - ...options, - isHandledCustomJSONSchema: true, - }) - - return { - ...json, - ...customJSONSchema, - } - } + condition(schema: Schema): boolean { + return Boolean(schema && schema['~standard'].vendor === 'zod') } - const childOptions = { ...options, isHandledCustomJSONSchema: false, isHandledZodDescription: false } - - const customType = getCustomZodType(schema__._def) - - switch (customType) { - case 'Blob': { - return { type: 'string', contentMediaType: '*/*' } - } - - case 'File': { - const mimeType = getCustomZodFileMimeType(schema__._def) ?? '*/*' - - return { type: 'string', contentMediaType: mimeType } - } + convert( + schema: Schema, + strategy: 'input' | 'output', + lazyDepth = 0, + isHandledCustomJSONSchema = false, + isHandledZodDescription = false, + ): [required: boolean, jsonSchema: Exclude] { + const def = (schema as ZodTypeAny)._def + + if (!isHandledZodDescription && 'description' in def && typeof def.description === 'string') { + const [required, json] = this.convert( + schema, + strategy, + lazyDepth, + isHandledCustomJSONSchema, + true, + ) - case 'Invalid Date': { - return { const: 'Invalid Date' } + return [required, json] } - case 'RegExp': { - return { - type: 'string', - pattern: '^\\/(.*)\\/([a-z]*)$', - } - } + if (!isHandledCustomJSONSchema) { + const customJSONSchema = getCustomJsonSchema(def, strategy) - case 'URL': { - return { type: 'string', format: JSONSchemaFormat.URI } - } - } + const [required, json] = this.convert( + schema, + strategy, + lazyDepth, + true, + isHandledZodDescription, + ) - const _expectedCustomType: undefined = customType - - const typeName = schema__._def.typeName as ZodFirstPartyTypeKind | undefined - - switch (typeName) { - case ZodFirstPartyTypeKind.ZodString: { - const schema_ = schema__ as ZodString - - const json: JSONSchema.JSONSchema = { type: 'string' } - - for (const check of schema_._def.checks) { - switch (check.kind) { - case 'base64': - json.contentEncoding = 'base64' - break - case 'cuid': - json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' - break - case 'email': - json.format = JSONSchemaFormat.Email - break - case 'url': - json.format = JSONSchemaFormat.URI - break - case 'uuid': - json.format = JSONSchemaFormat.UUID - break - case 'regex': - json.pattern = check.regex.source - break - case 'min': - json.minLength = check.value - break - case 'max': - json.maxLength = check.value - break - case 'length': - json.minLength = check.value - json.maxLength = check.value - break - case 'includes': - json.pattern = escapeStringRegexp(check.value) - break - case 'startsWith': - json.pattern = `^${escapeStringRegexp(check.value)}` - break - case 'endsWith': - json.pattern = `${escapeStringRegexp(check.value)}$` - break - case 'emoji': - json.pattern - = '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' - break - case 'nanoid': - json.pattern = '^[a-zA-Z0-9_-]{21}$' - break - case 'cuid2': - json.pattern = '^[0-9a-z]+$' - break - case 'ulid': - json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' - break - case 'datetime': - json.format = JSONSchemaFormat.DateTime - break - case 'date': - json.format = JSONSchemaFormat.Date - break - case 'time': - json.format = JSONSchemaFormat.Time - break - case 'duration': - json.format = JSONSchemaFormat.Duration - break - case 'ip': - json.format = JSONSchemaFormat.IPv4 - break - case 'jwt': - json.pattern = '^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$' - break - case 'base64url': - json.pattern = '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' - break - default: { - const _expect: 'toLowerCase' | 'toUpperCase' | 'trim' | 'cidr' = check.kind + return [required, { ...json, ...customJSONSchema }] + } + + const customSchema = this.handleCustomZodDef(def) + + if (customSchema) { + return [true, customSchema] + } + + const typeName = def.typeName as ZodFirstPartyTypeKind | undefined + + switch (typeName) { + case ZodFirstPartyTypeKind.ZodString: { + const schema_ = schema as ZodString + + const json: JSONSchema = { type: 'string' } + + for (const check of schema_._def.checks) { + switch (check.kind) { + case 'base64': + json.contentEncoding = 'base64' + break + case 'cuid': + json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' + break + case 'email': + json.format = JSONSchemaFormat.Email + break + case 'url': + json.format = JSONSchemaFormat.URI + break + case 'uuid': + json.format = JSONSchemaFormat.UUID + break + case 'regex': + json.pattern = check.regex.source + break + case 'min': + json.minLength = check.value + break + case 'max': + json.maxLength = check.value + break + case 'length': + json.minLength = check.value + json.maxLength = check.value + break + case 'includes': + json.pattern = escapeStringRegexp(check.value) + break + case 'startsWith': + json.pattern = `^${escapeStringRegexp(check.value)}` + break + case 'endsWith': + json.pattern = `${escapeStringRegexp(check.value)}$` + break + case 'emoji': + json.pattern + = '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' + break + case 'nanoid': + json.pattern = '^[a-zA-Z0-9_-]{21}$' + break + case 'cuid2': + json.pattern = '^[0-9a-z]+$' + break + case 'ulid': + json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' + break + case 'datetime': + json.format = JSONSchemaFormat.DateTime + break + case 'date': + json.format = JSONSchemaFormat.Date + break + case 'time': + json.format = JSONSchemaFormat.Time + break + case 'duration': + json.format = JSONSchemaFormat.Duration + break + case 'ip': + json.format = JSONSchemaFormat.IPv4 + break + case 'jwt': + json.pattern = '^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$' + break + case 'base64url': + json.pattern = '^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$' + break + default: { + const _expect: 'toLowerCase' | 'toUpperCase' | 'trim' | 'cidr' = check.kind + } } } - } - return json - } + return [true, json] + } - case ZodFirstPartyTypeKind.ZodNumber: { - const schema_ = schema__ as ZodNumber - - const json: JSONSchema.JSONSchema = { type: 'number' } - - for (const check of schema_._def.checks) { - switch (check.kind) { - case 'int': - json.type = 'integer' - break - case 'min': - json.minimum = check.value - break - case 'max': - json.maximum = check.value - break - case 'multipleOf': - json.multipleOf = check.value - break - default: { - const _expect: 'finite' = check.kind + case ZodFirstPartyTypeKind.ZodNumber: { + const schema_ = schema as ZodNumber + + const json: JSONSchema = { type: 'number' } + + for (const check of schema_._def.checks) { + switch (check.kind) { + case 'int': + json.type = 'integer' + break + case 'min': + json.minimum = check.value + break + case 'max': + json.maximum = check.value + break + case 'multipleOf': + json.multipleOf = check.value + break + default: { + const _expect: 'finite' = check.kind + } } } + + return [true, json] } - return json - } + case ZodFirstPartyTypeKind.ZodBigInt: { + const json: JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' } - case ZodFirstPartyTypeKind.ZodNaN: { - return { const: 'NaN' } - } + // WARN: ignore checks - case ZodFirstPartyTypeKind.ZodBigInt: { - const json: JSONSchema.JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' } + return [true, json] + } - // WARN: ignore checks + case ZodFirstPartyTypeKind.ZodBoolean: { + return [true, { type: 'boolean' }] + } - return json - } + case ZodFirstPartyTypeKind.ZodDate: { + const schema: JSONSchema = { type: 'string', format: JSONSchemaFormat.Date } - case ZodFirstPartyTypeKind.ZodBoolean: { - return { type: 'boolean' } - } + // WARN: ignore checks - case ZodFirstPartyTypeKind.ZodDate: { - const schema: JSONSchema.JSONSchema = { type: 'string', format: JSONSchemaFormat.Date } + return [true, schema] + } - // WARN: ignore checks + case ZodFirstPartyTypeKind.ZodNaN: + case ZodFirstPartyTypeKind.ZodNull: { + return [true, { type: 'null' }] + } - return schema - } + case ZodFirstPartyTypeKind.ZodVoid: + case ZodFirstPartyTypeKind.ZodUndefined: { + return [false, {}] + } - case ZodFirstPartyTypeKind.ZodNull: { - return { type: 'null' } - } + case ZodFirstPartyTypeKind.ZodLiteral: { + const schema_ = schema as ZodLiteral + return [true, { const: schema_._def.value }] + } - case ZodFirstPartyTypeKind.ZodVoid: - case ZodFirstPartyTypeKind.ZodUndefined: { - return UNDEFINED_JSON_SCHEMA - } + case ZodFirstPartyTypeKind.ZodEnum: { + const schema_ = schema as ZodEnum<[string, ...string[]]> - case ZodFirstPartyTypeKind.ZodLiteral: { - const schema_ = schema__ as ZodLiteral - return { const: schema_._def.value } - } + return [true, { enum: schema_._def.values }] + } - case ZodFirstPartyTypeKind.ZodEnum: { - const schema_ = schema__ as ZodEnum<[string, ...string[]]> + case ZodFirstPartyTypeKind.ZodNativeEnum: { + const schema_ = schema as ZodNativeEnum - return { - enum: schema_._def.values, + return [true, { enum: Object.values(schema_._def.values) }] } - } - case ZodFirstPartyTypeKind.ZodNativeEnum: { - const schema_ = schema__ as ZodNativeEnum + case ZodFirstPartyTypeKind.ZodArray: { + const schema_ = schema as ZodArray + const def = schema_._def - return { - enum: Object.values(schema_._def.values), - } - } + const json: JSONSchema = { type: 'array' } - case ZodFirstPartyTypeKind.ZodArray: { - const schema_ = schema__ as ZodArray - const def = schema_._def + const [itemRequired, itemJson] = this.convert(def.type, strategy, lazyDepth, false, false) - const json: JSONSchema.JSONSchema = { type: 'array' } + json.items = itemRequired + ? itemJson + : { + anyOf: [ + { type: 'null' }, + itemJson, + ], + } - json.items = zodToJsonSchema(def.type, childOptions) + if (def.exactLength) { + json.maxItems = def.exactLength.value + json.minItems = def.exactLength.value + } - if (def.exactLength) { - json.maxItems = def.exactLength.value - json.minItems = def.exactLength.value - } + if (def.minLength) { + json.minItems = def.minLength.value + } - if (def.minLength) { - json.minItems = def.minLength.value - } + if (def.maxLength) { + json.maxItems = def.maxLength.value + } - if (def.maxLength) { - json.maxItems = def.maxLength.value + return [true, json] } - return json - } + case ZodFirstPartyTypeKind.ZodTuple: { + const schema_ = schema as ZodTuple<[ZodTypeAny, ...ZodTypeAny[]], ZodTypeAny | null> + + const prefixItems: JSONSchema[] = [] + const json: JSONSchema = { type: 'array' } + + for (const item of schema_._def.items) { + const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) + + prefixItems.push( + itemRequired + ? itemJson + : { + anyOf: [ + { type: 'null' }, + itemJson, + ], + }, + ) + } - case ZodFirstPartyTypeKind.ZodTuple: { - const schema_ = schema__ as ZodTuple< - [ZodTypeAny, ...ZodTypeAny[]], - ZodTypeAny | null - > + if (prefixItems?.length) { + json.prefixItems = prefixItems + } - const prefixItems: JSONSchema.JSONSchema[] = [] - const json: JSONSchema.JSONSchema = { type: 'array' } + if (schema_._def.rest) { + const [itemRequired, itemJson] = this.convert(schema_._def.rest, strategy, lazyDepth, false, false) + + prefixItems.push( + itemRequired + ? itemJson + : { + anyOf: [ + { type: 'null' }, + itemJson, + ], + }, + ) + } - for (const item of schema_._def.items) { - prefixItems.push(zodToJsonSchema(item, childOptions)) + return [true, json] } - if (prefixItems?.length) { - json.prefixItems = prefixItems - } + case ZodFirstPartyTypeKind.ZodObject: { + const schema_ = schema as ZodObject - if (schema_._def.rest) { - const items = zodToJsonSchema(schema_._def.rest, childOptions) - if (items) { - json.items = items - } - } + const json: JSONSchema = { type: 'object' } + const properties: Record = {} + const required: string[] = [] - return json - } + for (const [key, value] of Object.entries(schema_.shape)) { + const [itemRequired, itemJson] = this.convert(value, strategy, lazyDepth, false, false) - case ZodFirstPartyTypeKind.ZodObject: { - const schema_ = schema__ as ZodObject + properties[key] = itemJson - const json: JSONSchema.JSONSchema = { type: 'object' } - const properties: Record = {} - const required: string[] = [] + if (itemRequired) { + required.push(key) + } + } - for (const [key, value] of Object.entries(schema_.shape)) { - const { schema, matches } = extractJSONSchema( - zodToJsonSchema(value, childOptions), - schema => schema === UNDEFINED_JSON_SCHEMA, - ) + if (Object.keys(properties).length) { + json.properties = properties + } - if (schema) { - properties[key] = schema + if (required.length) { + json.required = required } - if (matches.length === 0) { - required.push(key) + const [addRequired, addJson] = this.convert(schema_._def.catchall, strategy, lazyDepth, false, false) + + if (addRequired) { + json.additionalProperties = addJson } - } - if (Object.keys(properties).length) { - json.properties = properties + return [true, json] } - if (required.length) { - json.required = required - } + case ZodFirstPartyTypeKind.ZodRecord: { + const schema_ = schema as ZodRecord - const additionalProperties = zodToJsonSchema( - schema_._def.catchall, - childOptions, - ) - if (schema_._def.unknownKeys === 'strict') { - json.additionalProperties - = additionalProperties === UNSUPPORTED_JSON_SCHEMA - ? false - : additionalProperties - } - else { - if ( - additionalProperties - && additionalProperties !== UNSUPPORTED_JSON_SCHEMA - ) { - json.additionalProperties = additionalProperties - } - } + const json: JSONSchema = { type: 'object' } - return json - } + const [_, itemJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) - case ZodFirstPartyTypeKind.ZodRecord: { - const schema_ = schema__ as ZodRecord + json.additionalProperties = itemJson - const json: JSONSchema.JSONSchema = { type: 'object' } + return [true, json] + } - json.additionalProperties = zodToJsonSchema( - schema_._def.valueType, - childOptions, - ) + case ZodFirstPartyTypeKind.ZodSet: { + const schema_ = schema as ZodSet - return json - } + const json: JSONSchema = { type: 'array' } + + const [itemRequired, itemJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) - case ZodFirstPartyTypeKind.ZodSet: { - const schema_ = schema__ as ZodSet + json.items = itemRequired + ? itemJson + : { + anyOf: [ + { type: 'null' }, + itemJson, + ], + } - return { - type: 'array', - items: zodToJsonSchema(schema_._def.valueType, childOptions), + return [true, json] } - } - case ZodFirstPartyTypeKind.ZodMap: { - const schema_ = schema__ as ZodMap + case ZodFirstPartyTypeKind.ZodMap: { + const schema_ = schema as ZodMap + + const [keyRequired, keyJson] = this.convert(schema_._def.keyType, strategy, lazyDepth, false, false) + const [valueRequired, valueJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) - return { - type: 'array', - items: { + return [true, { type: 'array', - prefixItems: [ - zodToJsonSchema(schema_._def.keyType, childOptions), - zodToJsonSchema(schema_._def.valueType, childOptions), - ], - maxItems: 2, - minItems: 2, - }, + items: { + type: 'array', + prefixItems: [ + keyRequired ? keyJson : { anyOf: [{ type: 'null' }, keyJson] }, + valueRequired ? valueJson : { anyOf: [{ type: 'null' }, valueJson] }, + ], + maxItems: 2, + minItems: 2, + }, + }] } - } - case ZodFirstPartyTypeKind.ZodUnion: - case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: { - const schema_ = schema__ as - | ZodUnion - | ZodDiscriminatedUnion, ...ZodObject[]]> + case ZodFirstPartyTypeKind.ZodUnion: + case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: { + const schema_ = schema as + | ZodUnion + | ZodDiscriminatedUnion, ...ZodObject[]]> - const anyOf: JSONSchema.JSONSchema[] = [] + const anyOf: JSONSchema[] = [] + const required: true[] = [] - for (const s of schema_._def.options) { - anyOf.push(zodToJsonSchema(s, childOptions)) - } + for (const item of schema_._def.options) { + const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) - return { anyOf } - } + anyOf.push(itemJson) - case ZodFirstPartyTypeKind.ZodIntersection: { - const schema_ = schema__ as ZodIntersection - - const allOf: JSONSchema.JSONSchema[] = [] + if (itemRequired) { + required.push(true) + } + } - for (const s of [schema_._def.left, schema_._def.right]) { - allOf.push(zodToJsonSchema(s, childOptions)) + return [required.length !== anyOf.length, { anyOf }] } - return { allOf } - } + case ZodFirstPartyTypeKind.ZodIntersection: { + const schema_ = schema as ZodIntersection - case ZodFirstPartyTypeKind.ZodLazy: { - const schema_ = schema__ as ZodLazy + const allOf: JSONSchema[] = [] + const required: true[] = [] - const maxLazyDepth = childOptions?.maxLazyDepth ?? 5 - const lazyDepth = childOptions?.lazyDepth ?? 0 + for (const item of [schema_._def.left, schema_._def.right]) { + const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) - if (lazyDepth > maxLazyDepth) { - return {} - } + allOf.push(itemJson) - return zodToJsonSchema(schema_._def.getter(), { - ...childOptions, - lazyDepth: lazyDepth + 1, - }) - } + if (itemRequired) { + required.push(true) + } + } - case ZodFirstPartyTypeKind.ZodUnknown: - case ZodFirstPartyTypeKind.ZodAny: - case undefined: { - return {} - } + return [required.length !== allOf.length, { allOf }] + } - case ZodFirstPartyTypeKind.ZodOptional: { - const schema_ = schema__ as ZodOptional + case ZodFirstPartyTypeKind.ZodLazy: { + if (lazyDepth > this.maxLazyDepth) { + return [false, {}] + } - const inner = zodToJsonSchema(schema_._def.innerType, childOptions) + const schema_ = schema as ZodLazy - return { - anyOf: [UNDEFINED_JSON_SCHEMA, inner], + return this.convert(schema_._def.getter(), strategy, lazyDepth + 1, false, false) } - } - case ZodFirstPartyTypeKind.ZodReadonly: { - const schema_ = schema__ as ZodReadonly - return zodToJsonSchema(schema_._def.innerType, childOptions) - } + case ZodFirstPartyTypeKind.ZodUnknown: + case ZodFirstPartyTypeKind.ZodAny: + case undefined: { + return [false, {}] + } - case ZodFirstPartyTypeKind.ZodDefault: { - const schema_ = schema__ as ZodDefault - return zodToJsonSchema(schema_._def.innerType, childOptions) - } + case ZodFirstPartyTypeKind.ZodOptional: { + const schema_ = schema as ZodOptional - case ZodFirstPartyTypeKind.ZodEffects: { - const schema_ = schema__ as ZodEffects + const [_, inner] = this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) - if ( - schema_._def.effect.type === 'transform' - && childOptions?.mode === 'output' - ) { - return {} + return [false, inner] } - return zodToJsonSchema(schema_._def.schema, childOptions) - } + case ZodFirstPartyTypeKind.ZodReadonly: { + const schema_ = schema as ZodReadonly + return this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) + } - case ZodFirstPartyTypeKind.ZodCatch: { - const schema_ = schema__ as ZodCatch - return zodToJsonSchema(schema_._def.innerType, childOptions) - } + case ZodFirstPartyTypeKind.ZodDefault: { + const schema_ = schema as ZodDefault - case ZodFirstPartyTypeKind.ZodBranded: { - const schema_ = schema__ as ZodBranded - return zodToJsonSchema(schema_._def.type, childOptions) - } + const [_, json] = this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) - case ZodFirstPartyTypeKind.ZodPipeline: { - const schema_ = schema__ as ZodPipeline - return zodToJsonSchema( - childOptions?.mode === 'output' ? schema_._def.out : schema_._def.in, - childOptions, - ) - } + return [false, { default: schema_._def.defaultValue, ...json }] + } - case ZodFirstPartyTypeKind.ZodNullable: { - const schema_ = schema__ as ZodNullable + case ZodFirstPartyTypeKind.ZodEffects: { + const schema_ = schema as ZodEffects - const inner = zodToJsonSchema(schema_._def.innerType, childOptions) + if (schema_._def.effect.type === 'transform' && strategy === 'output') { + return [false, {}] + } - return { - anyOf: [{ type: 'null' }, inner], + return this.convert(schema_._def.schema, strategy, lazyDepth, false, false) } - } - } - const _expected: - | ZodFirstPartyTypeKind.ZodPromise - | ZodFirstPartyTypeKind.ZodSymbol - | ZodFirstPartyTypeKind.ZodFunction - | ZodFirstPartyTypeKind.ZodNever = typeName + case ZodFirstPartyTypeKind.ZodCatch: { + const schema_ = schema as ZodCatch + return this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) + } - return UNSUPPORTED_JSON_SCHEMA -} + case ZodFirstPartyTypeKind.ZodBranded: { + const schema_ = schema as ZodBranded + return this.convert(schema_._def.type, strategy, lazyDepth, false, false) + } -function extractJSONSchema( - schema: JSONSchema.JSONSchema, - check: (schema: JSONSchema.JSONSchema) => boolean, - matches: JSONSchema.JSONSchema[] = [], -): { schema: JSONSchema.JSONSchema | undefined, matches: JSONSchema.JSONSchema[] } { - if (check(schema)) { - matches.push(schema) - return { schema: undefined, matches } - } + case ZodFirstPartyTypeKind.ZodPipeline: { + const schema_ = schema as ZodPipeline - if (typeof schema === 'boolean') { - return { schema, matches } - } + return this.convert( + strategy === 'input' ? schema_._def.in : schema_._def.out, + strategy, + lazyDepth, + false, + false, + ) + } - // TODO: $ref + case ZodFirstPartyTypeKind.ZodNullable: { + const schema_ = schema as ZodNullable - if ( - schema.anyOf - && Object.keys(schema).every( - k => k === 'anyOf' || NON_LOGIC_KEYWORDS.includes(k as any), - ) - ) { - const anyOf = schema.anyOf - .map(s => extractJSONSchema(s, check, matches).schema) - .filter(v => !!v) + const [required, json] = this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) - if (anyOf.length === 1 && typeof anyOf[0] === 'object') { - return { schema: { ...schema, anyOf: undefined, ...anyOf[0] }, matches } + return [required, { anyOf: [{ type: 'null' }, json] }] + } } - return { - schema: { - ...schema, - anyOf, - }, - matches, - } - } + const _expected: + | ZodFirstPartyTypeKind.ZodPromise + | ZodFirstPartyTypeKind.ZodSymbol + | ZodFirstPartyTypeKind.ZodFunction + | ZodFirstPartyTypeKind.ZodNever = typeName - // TODO: $ref + return [false, {}] + } - if ( - schema.oneOf - && Object.keys(schema).every( - k => k === 'oneOf' || NON_LOGIC_KEYWORDS.includes(k as any), - ) - ) { - const oneOf = schema.oneOf - .map(s => extractJSONSchema(s, check, matches).schema) - .filter(v => !!v) + private handleCustomZodDef(def: ZodTypeDef): Exclude | undefined { + const customZodDef = getCustomZodDef(def) - if (oneOf.length === 1 && typeof oneOf[0] === 'object') { - return { schema: { ...schema, oneOf: undefined, ...oneOf[0] }, matches } + if (!customZodDef) { + return undefined } - return { - schema: { - ...schema, - oneOf, - }, - matches, - } - } + switch (customZodDef.type) { + case 'blob': { + return { type: 'string', contentMediaType: '*/*' } + } - return { schema, matches } -} + case 'file': { + return { type: 'string', contentMediaType: customZodDef.mimeType ?? '*/*' } + } -export class ZodToJsonSchemaConverter implements SchemaConverter { - condition(schema: Schema): boolean { - return Boolean(schema && schema['~standard'].vendor === 'zod') - } + case 'regexp': { + return { + type: 'string', + pattern: '^\\/(.*)\\/([a-z]*)$', + } + } - convert(schema: Schema, options: SchemaConvertOptions): JSONSchema.JSONSchema { // TODO - const jsonSchema = schema as ZodTypeAny - return zodToJsonSchema(jsonSchema, { mode: options.strategy }) + case 'url': { + return { type: 'string', format: JSONSchemaFormat.URI } + } + + /* v8 ignore next 3 */ + default: { + const _expect: never = customZodDef + } + } } } diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index cf4004cce..0e807e2ee 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -4,6 +4,7 @@ import { file } from './schemas/file' import { regexp } from './schemas/regexp' import { url } from './schemas/url' +export * from './converter' export * from './custom-json-schema' export * from './schemas/base' export * from './schemas/blob' From 54e58f5a3f786903f26395a658610e28f17a155d Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 10:52:41 +0700 Subject: [PATCH 03/11] wip --- packages/zod/package.json | 5 +- packages/zod/src/converter.test.ts | 447 +++++++++++++++++++++++++++++ packages/zod/src/converter.ts | 92 ++++-- pnpm-lock.yaml | 15 +- 4 files changed, 534 insertions(+), 25 deletions(-) create mode 100644 packages/zod/src/converter.test.ts diff --git a/packages/zod/package.json b/packages/zod/package.json index 8a25f690d..b13f0970f 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -44,6 +44,9 @@ "escape-string-regexp": "^5.0.0", "json-schema-typed": "^8.0.1", "wildcard-match": "^5.1.3", - "zod": "^3.24.1" + "zod": "^3.24.2" + }, + "devDependencies": { + "zod-to-json-schema": "^3.24.3" } } diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts new file mode 100644 index 000000000..5e8f604a2 --- /dev/null +++ b/packages/zod/src/converter.test.ts @@ -0,0 +1,447 @@ +import type { JSONSchema } from 'json-schema-typed' +import type { ZodTypeAny } from 'zod' +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' +import { ZodToJsonSchemaConverter } from './converter' +import { blob } from './schemas/blob' +import { file } from './schemas/file' +import { regexp } from './schemas/regexp' +import { url } from './schemas/url' + +const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boolean, JSONSchema], ignoreZodToJsonSchema?: boolean }[] = [ + { + 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.string().base64(), + input: [true, { type: 'string', contentEncoding: 'base64' }], + }, + { + schema: z.string().cuid(), + input: [true, { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.string().email(), + input: [true, { type: 'string', format: 'email' }], + }, + { + schema: z.string().url(), + input: [true, { type: 'string', format: 'uri' }], + }, + { + schema: z.string().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.string().emoji(), + input: [true, { type: 'string', pattern: '^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$' }], + }, + { + schema: z.string().nanoid(), + input: [true, { type: 'string', pattern: '^[a-zA-Z0-9_-]{21}$' }], + }, + { + schema: z.string().cuid2(), + input: [true, { type: 'string', pattern: '^[0-9a-z]+$' }], + }, + { + schema: z.string().ulid(), + input: [true, { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' }], + }, + { + schema: z.string().datetime(), + input: [true, { type: 'string', format: 'date-time' }], + }, + { + schema: z.string().date(), + input: [true, { type: 'string', format: 'date' }], + }, + { + schema: z.string().time(), + input: [true, { type: 'string', format: 'time' }], + }, + { + schema: z.string().duration(), + input: [true, { type: 'string', format: 'duration' }], + }, + { + schema: z.string().ip(), + input: [true, { type: 'string', anyOf: [{ format: 'ipv4' }, { format: 'ipv6' }] }], + }, + { + schema: z.string().ip({ version: 'v4' }), + input: [true, { type: 'string', format: 'ipv4' }], + }, + { + schema: z.string().ip({ version: 'v6' }), + input: [true, { type: 'string', format: 'ipv6' }], + }, + { + schema: z.string().jwt(), + input: [true, { type: 'string', pattern: '^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$' }], + }, + { + schema: z.string().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' }], + }, + { + 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]+$' }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.boolean(), + input: [true, { type: 'boolean' }], + }, + { + schema: z.date(), + input: [true, { type: 'string', format: 'date-time' }], + }, + // { + // schema: z.nan(), + // input: [true, { type: 'null' }], + // ignoreZodToJsonSchema: true, + // }, + { + schema: z.null(), + input: [true, { type: 'null' }], + }, + { + schema: z.union([z.string(), z.number()]), + input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.union([z.string(), z.number().optional()]), + input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], + ignoreZodToJsonSchema: true, + }, + // { + // schema: z.union([z.string(), z.undefined()]), + // input: [false, { anyOf: [{ type: 'string' }] }], + // ignoreZodToJsonSchema: true, + // }, + // { + // 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' }], + ignoreZodToJsonSchema: true, + }, + { + 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' }] }], + ignoreZodToJsonSchema: true, + }, + // { + // schema: z.undefined(), + // input: [false, { not: {} }], + // }, + // { + // schema: z.symbol(), + // input: [false, { not: {} }], + // }, + // { + // schema: z.function(), + // input: [false, { not: {} }], + // }, + // { + // schema: z.never(), + // input: [false, { not: {} }], + // }, + { + 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, + }, +] + +describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, output = input, ignoreZodToJsonSchema }) => { + describe.each([ + ['input'], + ['output'], + ] as const)('strategy: %s', (strategy) => { + const converter = new ZodToJsonSchemaConverter() + + it('flat', () => { + const [expectRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = converter.convert(schema, strategy) + + expect(required).toEqual(expectRequired) + expect(json).toEqual(expectedJson) + + if (!ignoreZodToJsonSchema) { + if (expectRequired) { + expect(expectedJson).toEqual({ + ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + $schema: undefined, + }) + } + else { + expect({ + anyOf: [{ not: {} }, expectedJson], + }).toEqual({ + ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + $schema: undefined, + }) + } + } + }) + + it('object', () => { + const testSchema = z.object({ value: schema }) + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = 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 }), + $schema: undefined, + additionalProperties: undefined, + }) + } + }) + + it('object with catchall', () => { + const testSchema = z.object({ value: schema }).catchall(schema) + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = converter.convert(testSchema, strategy) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + properties: { + value: expectedJson, + }, + required: expectedRequired ? ['value'] : undefined, + additionalProperties: expectedJson, + }) + + if (!ignoreZodToJsonSchema) { + expect(json).toEqual({ + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + $schema: undefined, + additionalProperties: expectedJson, + }) + } + }) + + it('strict object', () => { + const testSchema = z.object({ value: schema }).strict() + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = converter.convert(testSchema, strategy) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'object', + properties: { + value: expectedJson, + }, + required: expectedRequired ? ['value'] : undefined, + additionalProperties: false, + }) + + if (!ignoreZodToJsonSchema) { + expect(json).toEqual({ + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + $schema: undefined, + }) + } + }) + + it('set', () => { + const testSchema = z.set(schema) + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = converter.convert(testSchema, strategy) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + uniqueItems: true, + items: expectedRequired ? expectedJson : { anyOf: [{ type: 'null' }, expectedJson] }, + }) + + if (!ignoreZodToJsonSchema) { + expect({ + type: 'array', + uniqueItems: true, + items: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, + }).toEqual({ + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + $schema: undefined, + }) + } + }) + + it('map', () => { + const testSchema = z.map(schema, schema.optional()) + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = converter.convert(testSchema, strategy) + + expect(required).toEqual(true) + expect(json).toEqual({ + type: 'array', + items: { + type: 'array', + maxItems: 2, + minItems: 2, + prefixItems: [ + expectedRequired ? expectedJson : { anyOf: [{ type: 'null' }, expectedJson] }, + { anyOf: [{ type: 'null' }, expectedJson] }, + ], + }, + }) + + 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', () => { + const testSchema = z.record(schema) + const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + const [required, json] = 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/converter.ts b/packages/zod/src/converter.ts index 9e9c3b9a9..b9442451a 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -16,13 +16,31 @@ export interface ZodToJsonSchemaOptions { * @default 3 */ maxLazyDepth?: number + + /** + * The schema to be used when the Zod schema is unsupported. + * + * @default { not: {} } + */ + unsupportedJsonSchema?: Exclude + + /** + * The schema to be used to represent the any | unknown type. + * + * @default { } + */ + anyJsonSchema?: Exclude } export class ZodToJsonSchemaConverter { private readonly maxLazyDepth: Exclude + private readonly unsupportedJsonSchema: Exclude + private readonly anyJsonSchema: Exclude constructor(options: ZodToJsonSchemaOptions = {}) { this.maxLazyDepth = options.maxLazyDepth ?? 3 + this.unsupportedJsonSchema = options.unsupportedJsonSchema ?? { not: {} } + this.anyJsonSchema = options.anyJsonSchema ?? {} } condition(schema: Schema): boolean { @@ -70,7 +88,7 @@ export class ZodToJsonSchemaConverter { return [true, customSchema] } - const typeName = def.typeName as ZodFirstPartyTypeKind | undefined + const typeName = this.getZodTypeName(def) switch (typeName) { case ZodFirstPartyTypeKind.ZodString: { @@ -142,9 +160,22 @@ export class ZodToJsonSchemaConverter { case 'duration': json.format = JSONSchemaFormat.Duration break - case 'ip': - json.format = JSONSchemaFormat.IPv4 + case 'ip': { + if (check.version === 'v4') { + json.format = JSONSchemaFormat.IPv4 + } + else if (check.version === 'v6') { + json.format = JSONSchemaFormat.IPv6 + } + else { + json.anyOf = [ + { format: JSONSchemaFormat.IPv4 }, + { format: JSONSchemaFormat.IPv6 }, + ] + } + break + } case 'jwt': json.pattern = '^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$' break @@ -201,7 +232,7 @@ export class ZodToJsonSchemaConverter { } case ZodFirstPartyTypeKind.ZodDate: { - const schema: JSONSchema = { type: 'string', format: JSONSchemaFormat.Date } + const schema: JSONSchema = { type: 'string', format: JSONSchemaFormat.DateTime } // WARN: ignore checks @@ -213,11 +244,6 @@ export class ZodToJsonSchemaConverter { return [true, { type: 'null' }] } - case ZodFirstPartyTypeKind.ZodVoid: - case ZodFirstPartyTypeKind.ZodUndefined: { - return [false, {}] - } - case ZodFirstPartyTypeKind.ZodLiteral: { const schema_ = schema as ZodLiteral return [true, { const: schema_._def.value }] @@ -336,9 +362,16 @@ export class ZodToJsonSchemaConverter { json.required = required } - const [addRequired, addJson] = this.convert(schema_._def.catchall, strategy, lazyDepth, false, false) + const catchAllTypeName = this.getZodTypeName(schema_._def.catchall._def) + + if (catchAllTypeName === ZodFirstPartyTypeKind.ZodNever) { + if (schema_._def.unknownKeys === 'strict') { + json.additionalProperties = false + } + } + else { + const [_, addJson] = this.convert(schema_._def.catchall, strategy, lazyDepth, false, false) - if (addRequired) { json.additionalProperties = addJson } @@ -350,6 +383,8 @@ export class ZodToJsonSchemaConverter { const json: JSONSchema = { type: 'object' } + // WARN: ignore keyType + const [_, itemJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) json.additionalProperties = itemJson @@ -360,7 +395,7 @@ export class ZodToJsonSchemaConverter { case ZodFirstPartyTypeKind.ZodSet: { const schema_ = schema as ZodSet - const json: JSONSchema = { type: 'array' } + const json: JSONSchema = { type: 'array', uniqueItems: true } const [itemRequired, itemJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) @@ -403,19 +438,24 @@ export class ZodToJsonSchemaConverter { | ZodDiscriminatedUnion, ...ZodObject[]]> const anyOf: JSONSchema[] = [] - const required: true[] = [] + let required = true for (const item of schema_._def.options) { const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) - anyOf.push(itemJson) + if (!itemRequired) { + required = false - if (itemRequired) { - required.push(true) + if (itemJson !== this.unsupportedJsonSchema) { + anyOf.push(itemJson) + } + } + else { + anyOf.push(itemJson) } } - return [required.length !== anyOf.length, { anyOf }] + return [required, { anyOf }] } case ZodFirstPartyTypeKind.ZodIntersection: { @@ -439,7 +479,7 @@ export class ZodToJsonSchemaConverter { case ZodFirstPartyTypeKind.ZodLazy: { if (lazyDepth > this.maxLazyDepth) { - return [false, {}] + return [false, this.anyJsonSchema] } const schema_ = schema as ZodLazy @@ -448,9 +488,8 @@ export class ZodToJsonSchemaConverter { } case ZodFirstPartyTypeKind.ZodUnknown: - case ZodFirstPartyTypeKind.ZodAny: - case undefined: { - return [false, {}] + case ZodFirstPartyTypeKind.ZodAny: { + return [false, this.anyJsonSchema] } case ZodFirstPartyTypeKind.ZodOptional: { @@ -478,7 +517,7 @@ export class ZodToJsonSchemaConverter { const schema_ = schema as ZodEffects if (schema_._def.effect.type === 'transform' && strategy === 'output') { - return [false, {}] + return [false, this.anyJsonSchema] } return this.convert(schema_._def.schema, strategy, lazyDepth, false, false) @@ -516,12 +555,15 @@ export class ZodToJsonSchemaConverter { } const _expected: + | undefined + | ZodFirstPartyTypeKind.ZodVoid + | ZodFirstPartyTypeKind.ZodUndefined | ZodFirstPartyTypeKind.ZodPromise | ZodFirstPartyTypeKind.ZodSymbol | ZodFirstPartyTypeKind.ZodFunction | ZodFirstPartyTypeKind.ZodNever = typeName - return [false, {}] + return [false, this.unsupportedJsonSchema] } private handleCustomZodDef(def: ZodTypeDef): Exclude | undefined { @@ -557,4 +599,8 @@ export class ZodToJsonSchemaConverter { } } } + + private getZodTypeName(def: ZodTypeDef): ZodFirstPartyTypeKind | undefined { + return (def as any).typeName as ZodFirstPartyTypeKind | undefined + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dd7e4ef7..b37dda3c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,8 +378,12 @@ importers: specifier: ^5.1.3 version: 5.1.4 zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 + devDependencies: + zod-to-json-schema: + specifier: ^3.24.3 + version: 3.24.3(zod@3.24.2) playgrounds/contract-openapi: dependencies: @@ -7065,6 +7069,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.24.3: + resolution: {integrity: sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==} + peerDependencies: + zod: ^3.24.1 + zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} @@ -14678,6 +14687,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.24.3(zod@3.24.2): + dependencies: + zod: 3.24.2 + zod@3.24.2: {} zwitch@2.0.4: {} From b4dd6b0e6f2bf4e1750da84ac0dddafba7876f0a Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 15:29:41 +0700 Subject: [PATCH 04/11] wip --- packages/zod/src/converter.test.ts | 308 ++++++++++++++++++++++------- packages/zod/src/converter.ts | 119 ++++++----- 2 files changed, 297 insertions(+), 130 deletions(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 5e8f604a2..f5600c5ce 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -3,12 +3,20 @@ import type { ZodTypeAny } from 'zod' import { z } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' import { ZodToJsonSchemaConverter } from './converter' +import { customJsonSchema } from './custom-json-schema' import { blob } from './schemas/blob' import { file } from './schemas/file' import { regexp } from './schemas/regexp' import { url } from './schemas/url' -const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boolean, JSONSchema], ignoreZodToJsonSchema?: boolean }[] = [ +type SchemaTestCase = { + schema: ZodTypeAny + input: [boolean, JSONSchema] + output?: [boolean, JSONSchema] + ignoreZodToJsonSchema?: boolean +} + +const stringCases: SchemaTestCase[] = [ { schema: z.string(), input: [true, { type: 'string' }], @@ -110,6 +118,9 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole schema: z.string().trim(), input: [true, { type: 'string' }], }, +] + +const numberCases: SchemaTestCase[] = [ { schema: z.number(), input: [true, { type: 'number' }], @@ -135,6 +146,20 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole input: [true, { type: 'string', pattern: '^-?[0-9]+$' }], ignoreZodToJsonSchema: true, }, + { + schema: z.nan(), + input: [true, { not: {} }], + output: [true, { type: 'null' }], + ignoreZodToJsonSchema: true, + }, +] + +enum ExampleEnum { + A = 'a', + B = 'b', +} + +const nativeCases: SchemaTestCase[] = [ { schema: z.boolean(), input: [true, { type: 'boolean' }], @@ -143,15 +168,53 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole schema: z.date(), input: [true, { type: 'string', format: 'date-time' }], }, - // { - // schema: z.nan(), - // input: [true, { type: 'null' }], - // ignoreZodToJsonSchema: true, - // }, { schema: z.null(), input: [true, { type: 'null' }], }, + { + schema: z.any(), + input: [false, { }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.unknown(), + input: [false, {}], + ignoreZodToJsonSchema: true, + }, + { + schema: z.undefined(), + input: [false, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.void(), + input: [false, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.literal(1234), + input: [true, { const: 1234 }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.literal(undefined), + input: [false, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.enum(['a', 'b']), + input: [true, { enum: ['a', 'b'] }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.nativeEnum(ExampleEnum), + input: [true, { enum: ['a', 'b'] }], + ignoreZodToJsonSchema: true, + }, +] + +const combinationCases: SchemaTestCase[] = [ { schema: z.union([z.string(), z.number()]), input: [true, { anyOf: [{ type: 'string' }, { type: 'number' }] }], @@ -162,16 +225,39 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole input: [false, { anyOf: [{ type: 'string' }, { type: 'number' }] }], ignoreZodToJsonSchema: true, }, - // { - // schema: z.union([z.string(), z.undefined()]), - // input: [false, { anyOf: [{ type: 'string' }] }], - // ignoreZodToJsonSchema: true, - // }, - // { - // schema: z.string().transform(x => x), - // input: [true, { type: 'string' }], - // output: [false, { }], - // }, + { + schema: z.union([z.string(), z.undefined()]), + input: [false, { type: 'string' }], + ignoreZodToJsonSchema: true, + }, + { + 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' }] }], + ignoreZodToJsonSchema: true, + }, +] + +const processedCases: SchemaTestCase[] = [ + { + schema: z.lazy(() => z.object({ value: z.string() })), + input: [true, { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.lazy(() => z.object({ value: z.lazy(() => z.string()) })), + input: [true, { type: 'object', properties: { value: { } } }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.string().transform(x => x), + input: [true, { type: 'string' }], + output: [false, {}], + ignoreZodToJsonSchema: true, + }, { schema: z.string().refine(x => x.length > 0, 'not empty'), input: [true, { type: 'string' }], @@ -203,22 +289,41 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole input: [true, { anyOf: [{ type: 'null' }, { type: 'string' }] }], ignoreZodToJsonSchema: true, }, - // { - // schema: z.undefined(), - // input: [false, { not: {} }], - // }, - // { - // schema: z.symbol(), - // input: [false, { not: {} }], - // }, - // { - // schema: z.function(), - // input: [false, { not: {} }], - // }, - // { - // schema: z.never(), - // input: [false, { not: {} }], - // }, + { + schema: z.string().default('a'), + input: [false, { default: 'a', type: 'string' }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.number().readonly(), + input: [true, { type: 'number' }], + }, +] + +const unsupportedCases: SchemaTestCase[] = [ + { + schema: z.promise(z.string()), + input: [true, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.symbol(), + input: [true, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.function(), + input: [true, { not: {} }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.never(), + input: [true, { not: {} }], + ignoreZodToJsonSchema: true, + }, +] + +const extendSchemaCases: SchemaTestCase[] = [ { schema: file(), input: [true, { type: 'string', contentMediaType: '*/*' }], @@ -244,26 +349,91 @@ const cases: { schema: ZodTypeAny, input: [boolean, JSONSchema], output?: [boole 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'] }, + 'input', + ), + { examples: ['output'] }, + '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 }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.array(z.string()).min(10).max(20), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 20 }], + ignoreZodToJsonSchema: true, + }, + { + schema: z.array(z.string()).length(10), + input: [true, { type: 'array', items: { type: 'string' }, minItems: 10, maxItems: 10 }], + ignoreZodToJsonSchema: true, + }, + { + 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' } }], + }, ] -describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, output = input, ignoreZodToJsonSchema }) => { +describe.each([ + ...stringCases, + ...numberCases, + ...nativeCases, + ...combinationCases, + ...processedCases, + ...extendSchemaCases, + ...unsupportedCases, + ...edgeCases, +])('zodToJsonSchemaConverter.convert %#', ({ schema, input, output = input, ignoreZodToJsonSchema }) => { describe.each([ ['input'], ['output'], ] as const)('strategy: %s', (strategy) => { - const converter = new ZodToJsonSchemaConverter() + 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', () => { - const [expectRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = converter.convert(schema, strategy) - expect(required).toEqual(expectRequired) + expect(required).toEqual(expectedRequired) expect(json).toEqual(expectedJson) if (!ignoreZodToJsonSchema) { - if (expectRequired) { + if (expectedRequired) { expect(expectedJson).toEqual({ - ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), $schema: undefined, }) } @@ -271,7 +441,7 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou expect({ anyOf: [{ not: {} }, expectedJson], }).toEqual({ - ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + ...zodToJsonSchema(schema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), $schema: undefined, }) } @@ -280,7 +450,6 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou it('object', () => { const testSchema = z.object({ value: schema }) - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) @@ -294,70 +463,71 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou if (!ignoreZodToJsonSchema) { expect(json).toEqual({ - ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), $schema: undefined, additionalProperties: undefined, }) } }) - it('object with catchall', () => { - const testSchema = z.object({ value: schema }).catchall(schema) - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + it('array', () => { + const testSchema = z.array(schema) const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) expect(json).toEqual({ - type: 'object', - properties: { - value: expectedJson, - }, - required: expectedRequired ? ['value'] : undefined, - additionalProperties: expectedJson, + type: 'array', + items: arrayItemJsonSchema, }) if (!ignoreZodToJsonSchema) { expect(json).toEqual({ - ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), $schema: undefined, - additionalProperties: expectedJson, }) } }) - it('strict object', () => { - const testSchema = z.object({ value: schema }).strict() - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output + it('tuple', () => { + const testSchema = z.tuple([schema, schema]).rest(schema) const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) expect(json).toEqual({ - type: 'object', - properties: { - value: expectedJson, - }, - required: expectedRequired ? ['value'] : undefined, - additionalProperties: false, + type: 'array', + prefixItems: [ + arrayItemJsonSchema, + arrayItemJsonSchema, + ], + items: arrayItemJsonSchema, }) if (!ignoreZodToJsonSchema) { - expect(json).toEqual({ - ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + 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', () => { const testSchema = z.set(schema) - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) expect(json).toEqual({ type: 'array', uniqueItems: true, - items: expectedRequired ? expectedJson : { anyOf: [{ type: 'null' }, expectedJson] }, + items: arrayItemJsonSchema, }) if (!ignoreZodToJsonSchema) { @@ -366,7 +536,7 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou uniqueItems: true, items: expectedRequired ? expectedJson : { anyOf: [{ not: {} }, expectedJson] }, }).toEqual({ - ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy }), + ...zodToJsonSchema(testSchema, { target: 'jsonSchema2019-09', pipeStrategy: strategy, $refStrategy: 'none' }), $schema: undefined, }) } @@ -374,7 +544,6 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou it('map', () => { const testSchema = z.map(schema, schema.optional()) - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) @@ -385,8 +554,8 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou maxItems: 2, minItems: 2, prefixItems: [ - expectedRequired ? expectedJson : { anyOf: [{ type: 'null' }, expectedJson] }, - { anyOf: [{ type: 'null' }, expectedJson] }, + arrayItemJsonSchema, + { anyOf: [expectedJson, strategy === 'input' ? { not: {} } : { type: 'null' }] }, ], }, }) @@ -413,7 +582,6 @@ describe.each(cases)('zodToJsonSchemaConverter.convert %#', ({ schema, input, ou it('record', () => { const testSchema = z.record(schema) - const [expectedRequired, expectedJson] = strategy === 'input' ? input : output const [required, json] = converter.convert(testSchema, strategy) expect(required).toEqual(true) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index b9442451a..31758f474 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -65,21 +65,23 @@ export class ZodToJsonSchemaConverter { true, ) - return [required, json] + return [required, { ...json, description: def.description }] } if (!isHandledCustomJSONSchema) { const customJSONSchema = getCustomJsonSchema(def, strategy) - const [required, json] = this.convert( - schema, - strategy, - lazyDepth, - true, - isHandledZodDescription, - ) + if (customJSONSchema) { + const [required, json] = this.convert( + schema, + strategy, + lazyDepth, + true, + isHandledZodDescription, + ) - return [required, { ...json, ...customJSONSchema }] + return [required, { ...json, ...customJSONSchema }] + } } const customSchema = this.handleCustomZodDef(def) @@ -227,6 +229,12 @@ export class ZodToJsonSchemaConverter { return [true, json] } + case ZodFirstPartyTypeKind.ZodNaN: { + return strategy === 'input' + ? [true, this.unsupportedJsonSchema] + : [true, { type: 'null' }] + } + case ZodFirstPartyTypeKind.ZodBoolean: { return [true, { type: 'boolean' }] } @@ -239,16 +247,30 @@ export class ZodToJsonSchemaConverter { return [true, schema] } - case ZodFirstPartyTypeKind.ZodNaN: case ZodFirstPartyTypeKind.ZodNull: { return [true, { type: 'null' }] } case ZodFirstPartyTypeKind.ZodLiteral: { const schema_ = schema as ZodLiteral + + if (schema_._def.value === undefined) { + return [false, this.unsupportedJsonSchema] + } + return [true, { const: schema_._def.value }] } + case ZodFirstPartyTypeKind.ZodVoid: + case ZodFirstPartyTypeKind.ZodUndefined: { + return [false, this.unsupportedJsonSchema] + } + + case ZodFirstPartyTypeKind.ZodUnknown: + case ZodFirstPartyTypeKind.ZodAny: { + return [false, this.anyJsonSchema] + } + case ZodFirstPartyTypeKind.ZodEnum: { const schema_ = schema as ZodEnum<[string, ...string[]]> @@ -269,14 +291,7 @@ export class ZodToJsonSchemaConverter { const [itemRequired, itemJson] = this.convert(def.type, strategy, lazyDepth, false, false) - json.items = itemRequired - ? itemJson - : { - anyOf: [ - { type: 'null' }, - itemJson, - ], - } + json.items = this.toArrayItemJsonSchema(itemRequired, itemJson, strategy) if (def.exactLength) { json.maxItems = def.exactLength.value @@ -304,14 +319,7 @@ export class ZodToJsonSchemaConverter { const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) prefixItems.push( - itemRequired - ? itemJson - : { - anyOf: [ - { type: 'null' }, - itemJson, - ], - }, + this.toArrayItemJsonSchema(itemRequired, itemJson, strategy), ) } @@ -322,16 +330,7 @@ export class ZodToJsonSchemaConverter { if (schema_._def.rest) { const [itemRequired, itemJson] = this.convert(schema_._def.rest, strategy, lazyDepth, false, false) - prefixItems.push( - itemRequired - ? itemJson - : { - anyOf: [ - { type: 'null' }, - itemJson, - ], - }, - ) + json.items = this.toArrayItemJsonSchema(itemRequired, itemJson, strategy) } return [true, json] @@ -399,14 +398,7 @@ export class ZodToJsonSchemaConverter { const [itemRequired, itemJson] = this.convert(schema_._def.valueType, strategy, lazyDepth, false, false) - json.items = itemRequired - ? itemJson - : { - anyOf: [ - { type: 'null' }, - itemJson, - ], - } + json.items = this.toArrayItemJsonSchema(itemRequired, itemJson, strategy) return [true, json] } @@ -422,8 +414,8 @@ export class ZodToJsonSchemaConverter { items: { type: 'array', prefixItems: [ - keyRequired ? keyJson : { anyOf: [{ type: 'null' }, keyJson] }, - valueRequired ? valueJson : { anyOf: [{ type: 'null' }, valueJson] }, + this.toArrayItemJsonSchema(keyRequired, keyJson, strategy), + this.toArrayItemJsonSchema(valueRequired, valueJson, strategy), ], maxItems: 2, minItems: 2, @@ -437,7 +429,7 @@ export class ZodToJsonSchemaConverter { | ZodUnion | ZodDiscriminatedUnion, ...ZodObject[]]> - const anyOf: JSONSchema[] = [] + const anyOf: Exclude[] = [] let required = true for (const item of schema_._def.options) { @@ -455,6 +447,10 @@ export class ZodToJsonSchemaConverter { } } + if (anyOf.length === 1) { + return [required, anyOf[0]!] + } + return [required, { anyOf }] } @@ -462,7 +458,7 @@ export class ZodToJsonSchemaConverter { const schema_ = schema as ZodIntersection const allOf: JSONSchema[] = [] - const required: true[] = [] + let required: boolean = false for (const item of [schema_._def.left, schema_._def.right]) { const [itemRequired, itemJson] = this.convert(item, strategy, lazyDepth, false, false) @@ -470,15 +466,15 @@ export class ZodToJsonSchemaConverter { allOf.push(itemJson) if (itemRequired) { - required.push(true) + required = true } } - return [required.length !== allOf.length, { allOf }] + return [required, { allOf }] } case ZodFirstPartyTypeKind.ZodLazy: { - if (lazyDepth > this.maxLazyDepth) { + if (lazyDepth >= this.maxLazyDepth) { return [false, this.anyJsonSchema] } @@ -487,11 +483,6 @@ export class ZodToJsonSchemaConverter { return this.convert(schema_._def.getter(), strategy, lazyDepth + 1, false, false) } - case ZodFirstPartyTypeKind.ZodUnknown: - case ZodFirstPartyTypeKind.ZodAny: { - return [false, this.anyJsonSchema] - } - case ZodFirstPartyTypeKind.ZodOptional: { const schema_ = schema as ZodOptional @@ -510,7 +501,7 @@ export class ZodToJsonSchemaConverter { const [_, json] = this.convert(schema_._def.innerType, strategy, lazyDepth, false, false) - return [false, { default: schema_._def.defaultValue, ...json }] + return [false, { default: schema_._def.defaultValue(), ...json }] } case ZodFirstPartyTypeKind.ZodEffects: { @@ -556,14 +547,12 @@ export class ZodToJsonSchemaConverter { const _expected: | undefined - | ZodFirstPartyTypeKind.ZodVoid - | ZodFirstPartyTypeKind.ZodUndefined | ZodFirstPartyTypeKind.ZodPromise | ZodFirstPartyTypeKind.ZodSymbol | ZodFirstPartyTypeKind.ZodFunction | ZodFirstPartyTypeKind.ZodNever = typeName - return [false, this.unsupportedJsonSchema] + return [true, this.unsupportedJsonSchema] } private handleCustomZodDef(def: ZodTypeDef): Exclude | undefined { @@ -603,4 +592,14 @@ export class ZodToJsonSchemaConverter { private getZodTypeName(def: ZodTypeDef): ZodFirstPartyTypeKind | undefined { return (def as any).typeName as ZodFirstPartyTypeKind | undefined } + + private toArrayItemJsonSchema(required: boolean, schema: Exclude, strategy: 'input' | 'output'): Exclude { + if (required) { + return schema + } + + return strategy === 'input' + ? { anyOf: [schema, this.unsupportedJsonSchema] } + : { anyOf: [schema, { type: 'null' }] } + } } From f9dbf91db461b492670da78bebb93b21324dc79c Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 16:50:47 +0700 Subject: [PATCH 05/11] wip --- packages/zod/src/coercer.ts | 687 ++++++++++++++---------------------- 1 file changed, 270 insertions(+), 417 deletions(-) diff --git a/packages/zod/src/coercer.ts b/packages/zod/src/coercer.ts index 1eafb6769..239d2e004 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -1,32 +1,33 @@ import type { Context } from '@orpc/server' import type { Plugin } from '@orpc/server/plugins' import type { StandardHandlerOptions } from '@orpc/server/standard' -import { guard, isObject } from '@orpc/shared' -import { getCustomZodType } from '@orpc/zod' -import { - type EnumLike, - type ZodArray, - type ZodBranded, - type ZodCatch, - type ZodDefault, - type ZodDiscriminatedUnion, - type ZodEffects, - ZodFirstPartyTypeKind, - type ZodIntersection, - type ZodLazy, - type ZodLiteral, - type ZodMap, - type ZodNativeEnum, - type ZodNullable, - type ZodObject, - type ZodOptional, - type ZodPipeline, - type ZodReadonly, - type ZodRecord, - type ZodSet, - type ZodTypeAny, - type ZodUnion, +import type { + EnumLike, + ZodArray, + ZodBranded, + ZodCatch, + ZodDefault, + ZodDiscriminatedUnion, + ZodEffects, + ZodIntersection, + ZodLazy, + ZodLiteral, + ZodMap, + ZodNativeEnum, + ZodNullable, + ZodObject, + ZodOptional, + ZodPipeline, + ZodReadonly, + ZodRecord, + ZodSet, + ZodTuple, + ZodTypeAny, + ZodUnion, } from 'zod' +import { guard, isObject } from '@orpc/shared' +import { ZodFirstPartyTypeKind } from 'zod' +import { getCustomZodDef } from './schemas/base' export class ZodSmartCoercionPlugin implements Plugin { init(options: StandardHandlerOptions): void { @@ -39,519 +40,371 @@ export class ZodSmartCoercionPlugin implements Plugin< return options.next() } - const coercedInput = zodCoerceInternal(inputSchema as ZodTypeAny, options.input, { bracketNotation: true }) + const coercedInput = zodCoerceInternal(inputSchema as ZodTypeAny, options.input) return options.next({ ...options, input: coercedInput }) }) } } -export { - /** - * @deprecated ZodAutoCoercePlugin has renamed to ZodSmartCoercionPlugin - */ - ZodSmartCoercionPlugin as ZodAutoCoercePlugin, -} - function zodCoerceInternal( schema: ZodTypeAny, value: unknown, - options?: { isRoot?: boolean, bracketNotation?: boolean }, ): unknown { - const isRoot = options?.isRoot ?? true - const options_ = { ...options, isRoot: false } - - if ( - isRoot - && options?.bracketNotation - && Array.isArray(value) - && value.length === 1 - ) { - const newValue = zodCoerceInternal(schema, value[0], options_) - if (schema.safeParse(newValue).success) { - return newValue - } - return zodCoerceInternal(schema, value, options_) - } + const customZodDef = getCustomZodDef(schema._def) - const customType = getCustomZodType(schema._def) + switch (customZodDef?.type) { + case 'regexp': { + if (typeof value === 'string') { + return safeToRegExp(value) + } - if (customType === 'Invalid Date') { - if ( - typeof value === 'string' - && value.toLocaleLowerCase() === 'invalid date' - ) { - return new Date('Invalid Date') + return value } - } - else if (customType === 'RegExp') { - if (typeof value === 'string' && value.startsWith('/')) { - const match = value.match(/^\/(.*)\/([a-z]*)$/) - if (match) { - const [, pattern, flags] = match - return new RegExp(pattern!, flags) - } - } - } - else if (customType === 'URL') { - if (typeof value === 'string') { - const url = guard(() => new URL(value)) - if (url !== undefined) { - return url + case 'url': { + if (typeof value === 'string') { + return safeToURL(value) } - } - } - if (schema._def.typeName === undefined) { - return value + return value + } } - const typeName = schema._def.typeName as ZodFirstPartyTypeKind + const typeName = schema._def.typeName as ZodFirstPartyTypeKind | undefined - if (typeName === ZodFirstPartyTypeKind.ZodNumber) { - if (options_?.bracketNotation && typeof value === 'string') { - const num = Number(value) - if (!Number.isNaN(num)) { - return num + switch (typeName) { + case ZodFirstPartyTypeKind.ZodNumber: { + if (typeof value === 'string') { + return safeToNumber(value) } - } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodNaN) { - if (typeof value === 'string' && value.toLocaleLowerCase() === 'nan') { - return Number.NaN + return value } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodBoolean) { - if (options_?.bracketNotation && typeof value === 'string') { - const lower = value.toLowerCase() - - if (lower === 'false' || lower === 'off' || lower === 'f') { - return false - } - - if (lower === 'true' || lower === 'on' || lower === 't') { - return true + case ZodFirstPartyTypeKind.ZodBigInt: { + if (typeof value === 'string') { + return safeToBigInt(value) } - } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodNull) { - if ( - options_?.bracketNotation - && typeof value === 'string' - && value.toLowerCase() === 'null' - ) { - return null + return value } - } - // - else if ( - typeName === ZodFirstPartyTypeKind.ZodUndefined - || typeName === ZodFirstPartyTypeKind.ZodVoid - ) { - if (typeof value === 'string' && value.toLowerCase() === 'undefined') { - return undefined - } - } + case ZodFirstPartyTypeKind.ZodBoolean: { + if (typeof value === 'string') { + return safeToBoolean(value) + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodDate) { - if ( - typeof value === 'string' - && (value.includes('-') - || value.includes(':') - || value.toLocaleLowerCase() === 'invalid date') - ) { - return new Date(value) + return value } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodBigInt) { - if (typeof value === 'string') { - const num = guard(() => BigInt(value)) - if (num !== undefined) { - return num + case ZodFirstPartyTypeKind.ZodDate: { + if (typeof value === 'string') { + return safeToDate(value) } - } - } - - // - else if ( - typeName === ZodFirstPartyTypeKind.ZodArray - || typeName === ZodFirstPartyTypeKind.ZodTuple - ) { - const schema_ = schema as ZodArray - if (Array.isArray(value)) { - return value.map(v => zodCoerceInternal(schema_._def.type, v, options_)) + return value } - if (options_?.bracketNotation) { - if (value === undefined) { - return [] - } + case ZodFirstPartyTypeKind.ZodArray: { + const schema_ = schema as ZodArray - if ( - isObject(value) - && Object.keys(value).every(k => /^[1-9]\d*$/.test(k) || k === '0') - ) { - const indexes = Object.keys(value) - .map(k => Number(k)) - .sort((a, b) => a - b) + if (Array.isArray(value)) { + return value.map(v => zodCoerceInternal(schema_._def.type, v)) + } - const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) + return value + } - for (const i of indexes) { - arr[i] = zodCoerceInternal(schema_._def.type, value[i], options_) - } + case ZodFirstPartyTypeKind.ZodTuple: { + const schema_ = schema as ZodTuple<[ZodTypeAny, ...ZodTypeAny[]], ZodTypeAny | null> - return arr + if (Array.isArray(value)) { + return value.map((v, i) => { + const s = schema_._def.items[i] ?? schema_._def.rest + return s ? zodCoerceInternal(s, v) : v + }) } + + return value } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodObject) { - const schema_ = schema as ZodObject<{ [k: string]: ZodTypeAny }> + case ZodFirstPartyTypeKind.ZodObject: { + const schema_ = schema as ZodObject<{ [k: string]: ZodTypeAny }> - if (isObject(value)) { - const newObj: Record = {} + if (isObject(value)) { + const newObj: Record = {} - const keys = new Set([ - ...Object.keys(value), - ...Object.keys(schema_.shape), - ]) + const keys = new Set([ + ...Object.keys(value), + ...Object.keys(schema_.shape), + ]) - for (const k of keys) { - if (!(k in value)) - continue + for (const k of keys) { + newObj[k] = zodCoerceInternal( + schema_.shape[k] ?? schema_._def.catchall, + value[k], + ) + } - const v = value[k] - newObj[k] = zodCoerceInternal( - schema_.shape[k] ?? schema_._def.catchall, - v, - options_, - ) + return newObj } - return newObj + return value } - if (options_?.bracketNotation) { - if (value === undefined) { - return {} - } + case ZodFirstPartyTypeKind.ZodSet: { + const schema_ = schema as ZodSet - if (Array.isArray(value) && value.length === 1) { - const emptySchema = schema_.shape[''] ?? schema_._def.catchall - return { '': zodCoerceInternal(emptySchema, value[0], options_) } + if (Array.isArray(value)) { + return new Set( + value.map(v => zodCoerceInternal(schema_._def.valueType, v)), + ) } - } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodSet) { - const schema_ = schema as ZodSet - - if (Array.isArray(value)) { - return new Set( - value.map(v => zodCoerceInternal(schema_._def.valueType, v, options_)), - ) + return value } - if (options_?.bracketNotation) { - if (value === undefined) { - return new Set() - } + case ZodFirstPartyTypeKind.ZodMap : { + const schema_ = schema as ZodMap if ( - isObject(value) - && Object.keys(value).every(k => /^[1-9]\d*$/.test(k) || k === '0') + Array.isArray(value) + && value.every(i => Array.isArray(i) && i.length === 2) ) { - const indexes = Object.keys(value) - .map(k => Number(k)) - .sort((a, b) => a - b) + return new Map( + value.map(([k, v]) => [ + zodCoerceInternal(schema_._def.keyType, k), + zodCoerceInternal(schema_._def.valueType, v), + ]), + ) + } - const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) + return value + } + + case ZodFirstPartyTypeKind.ZodRecord: { + const schema_ = schema as ZodRecord + + if (isObject(value)) { + const newObj: any = {} - for (const i of indexes) { - arr[i] = zodCoerceInternal(schema_._def.valueType, value[i], options_) + for (const [k, v] of Object.entries(value)) { + const key = zodCoerceInternal(schema_._def.keyType, k) + const val = zodCoerceInternal(schema_._def.valueType, v) + newObj[key as any] = val } - return new Set(arr) + return newObj } - } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodMap) { - const schema_ = schema as ZodMap - - if ( - Array.isArray(value) - && value.every(i => Array.isArray(i) && i.length === 2) - ) { - return new Map( - value.map(([k, v]) => [ - zodCoerceInternal(schema_._def.keyType, k, options_), - zodCoerceInternal(schema_._def.valueType, v, options_), - ]), - ) + return value } - if (options_?.bracketNotation) { - if (value === undefined) { - return new Map() + case ZodFirstPartyTypeKind.ZodUnion: + case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: { + const schema_ = schema as + | ZodUnion<[ZodTypeAny]> + | ZodDiscriminatedUnion]> + + if (schema_.safeParse(value).success) { + return value } - if (isObject(value)) { - const arr = Array.from({ length: Object.keys(value).length }) - .fill(undefined) - .map((_, i) => - isObject(value[i]) - && Object.keys(value[i]).length === 2 - && '0' in value[i] - && '1' in value[i] - ? ([value[i]['0'], value[i]['1']] as const) - : undefined, - ) + const results: [unknown, number][] = [] + for (const s of schema_._def.options) { + const newValue = zodCoerceInternal(s, value) - if (arr.every(v => !!v)) { - return new Map( - arr.map(([k, v]) => [ - zodCoerceInternal(schema_._def.keyType, k, options_), - zodCoerceInternal(schema_._def.valueType, v, options_), - ]), - ) + if (newValue === value) { + continue } - } - } - } - // - else if (typeName === ZodFirstPartyTypeKind.ZodRecord) { - const schema_ = schema as ZodRecord + const result = schema_.safeParse(newValue) + + if (result.success) { + return newValue + } - if (isObject(value)) { - const newObj: any = {} + results.push([newValue, result.error.issues.length]) + } - for (const [k, v] of Object.entries(value)) { - const key = zodCoerceInternal(schema_._def.keyType, k, options_) as any - const val = zodCoerceInternal(schema_._def.valueType, v, options_) - newObj[key] = val + if (results.length === 0) { + return value } - return newObj + return results.sort((a, b) => a[1] - b[1])[0]![0] } - } - // - else if ( - typeName === ZodFirstPartyTypeKind.ZodUnion - || typeName === ZodFirstPartyTypeKind.ZodDiscriminatedUnion - ) { - const schema_ = schema as - | ZodUnion<[ZodTypeAny]> - | ZodDiscriminatedUnion]> + case ZodFirstPartyTypeKind.ZodIntersection: { + const schema_ = schema as ZodIntersection - if (schema_.safeParse(value).success) { - return value + return zodCoerceInternal( + schema_._def.right, + zodCoerceInternal(schema_._def.left, value), + ) } - const results: [unknown, number][] = [] - for (const s of schema_._def.options) { - const newValue = zodCoerceInternal(s, value, { ...options_, isRoot }) + case ZodFirstPartyTypeKind.ZodReadonly :{ + const schema_ = schema as ZodReadonly + return zodCoerceInternal(schema_._def.innerType, value) + } - if (newValue === value) - continue + case ZodFirstPartyTypeKind.ZodPipeline: { + const schema_ = schema as ZodPipeline + return zodCoerceInternal(schema_._def.in, value) + } - const result = schema_.safeParse(newValue) + case ZodFirstPartyTypeKind.ZodLazy: { + const schema_ = schema as ZodLazy - if (result.success) { - return newValue + if (value !== undefined) { + return zodCoerceInternal(schema_._def.getter(), value) } - results.push([newValue, result.error.issues.length]) - } - - if (results.length === 0) { return value } - return results.sort((a, b) => a[1] - b[1])[0]?.[0] - } + case ZodFirstPartyTypeKind.ZodEffects: { + const schema_ = schema as ZodEffects + return zodCoerceInternal(schema_._def.schema, value) + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodIntersection) { - const schema_ = schema as ZodIntersection + case ZodFirstPartyTypeKind.ZodBranded: { + const schema_ = schema as ZodBranded + return zodCoerceInternal(schema_._def.type, value) + } - return zodCoerceInternal( - schema_._def.right, - zodCoerceInternal(schema_._def.left, value, { ...options_, isRoot }), - { ...options_, isRoot }, - ) - } + case ZodFirstPartyTypeKind.ZodCatch: { + const schema_ = schema as ZodCatch + return zodCoerceInternal(schema_._def.innerType, value) + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodReadonly) { - const schema_ = schema as ZodReadonly + case ZodFirstPartyTypeKind.ZodDefault: { + const schema_ = schema as ZodDefault + return zodCoerceInternal(schema_._def.innerType, value) + } - return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) - } + case ZodFirstPartyTypeKind.ZodNullable: { + if (value === null) { + return null + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodPipeline) { - const schema_ = schema as ZodPipeline + const schema_ = schema as ZodNullable + return zodCoerceInternal(schema_._def.innerType, value) + } - return zodCoerceInternal(schema_._def.in, value, { ...options_, isRoot }) - } + case ZodFirstPartyTypeKind.ZodOptional: { + if (value === undefined) { + return undefined + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodLazy) { - const schema_ = schema as ZodLazy + const schema_ = schema as ZodOptional + return zodCoerceInternal(schema_._def.innerType, value) + } - return zodCoerceInternal(schema_._def.getter(), value, { ...options_, isRoot }) - } + case ZodFirstPartyTypeKind.ZodNativeEnum: { + const schema_ = schema as ZodNativeEnum - // - else if (typeName === ZodFirstPartyTypeKind.ZodEffects) { - const schema_ = schema as ZodEffects + if (Object.values(schema_._def.values).includes(value as any)) { + return value + } - return zodCoerceInternal(schema_._def.schema, value, { ...options_, isRoot }) - } + if (typeof value === 'string') { + for (const expectedValue of Object.values(schema_._def.values)) { + if (expectedValue.toString() === value) { + return expectedValue + } + } + } - // - else if (typeName === ZodFirstPartyTypeKind.ZodBranded) { - const schema_ = schema as ZodBranded + return value + } - return zodCoerceInternal(schema_._def.type, value, { ...options_, isRoot }) - } + case ZodFirstPartyTypeKind.ZodLiteral: { + const schema_ = schema as ZodLiteral + const expectedValue = schema_._def.value - // - else if (typeName === ZodFirstPartyTypeKind.ZodCatch) { - const schema_ = schema as ZodCatch + if (typeof value === 'string' && typeof expectedValue !== 'string') { + if (typeof expectedValue === 'bigint') { + return safeToBigInt(value) + } + else if (typeof expectedValue === 'number') { + return safeToNumber(value) + } + else if (typeof expectedValue === 'boolean') { + return safeToBoolean(value) + } + } - return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + return value + } } - // - else if (typeName === ZodFirstPartyTypeKind.ZodDefault) { - const schema_ = schema as ZodDefault + const _unsupported: + | undefined + | ZodFirstPartyTypeKind.ZodUndefined + | ZodFirstPartyTypeKind.ZodVoid + | ZodFirstPartyTypeKind.ZodNaN + | ZodFirstPartyTypeKind.ZodNull + | ZodFirstPartyTypeKind.ZodString + | ZodFirstPartyTypeKind.ZodEnum + | ZodFirstPartyTypeKind.ZodSymbol + | ZodFirstPartyTypeKind.ZodPromise + | ZodFirstPartyTypeKind.ZodFunction + | ZodFirstPartyTypeKind.ZodAny + | ZodFirstPartyTypeKind.ZodUnknown + | ZodFirstPartyTypeKind.ZodNever = typeName - return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) - } + return value +} - // - else if (typeName === ZodFirstPartyTypeKind.ZodNullable) { - const schema_ = schema as ZodNullable +function safeToBigInt(value: string): bigint | string { + return guard(() => BigInt(value)) ?? value +} - if (value === null) { - return null - } +function safeToNumber(value: string): number | string { + const num = Number(value) + return Number.isNaN(num) || num.toString() !== value ? value : num +} - if (typeof value === 'string' && value.toLowerCase() === 'null') { - return schema_.safeParse(value).success ? value : null - } +function safeToBoolean(value: string): boolean | string { + const lower = value.toLowerCase() - return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + if (lower === 'false' || lower === 'off' || lower === 'f') { + return false } - // - else if (typeName === ZodFirstPartyTypeKind.ZodOptional) { - const schema_ = schema as ZodOptional - - if (value === undefined) { - return undefined - } - - if (typeof value === 'string' && value.toLowerCase() === 'undefined') { - return schema_.safeParse(value).success ? value : undefined - } - - return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + if (lower === 'true' || lower === 'on' || lower === 't') { + return true } - // - else if (typeName === ZodFirstPartyTypeKind.ZodNativeEnum) { - const schema_ = schema as ZodNativeEnum + return value +} - if (Object.values(schema_._def.values).includes(value as any)) { - return value - } +function safeToRegExp(value: string): RegExp | string { + if (value.startsWith('/')) { + const match = value.match(/^\/(.*)\/([a-z]*)$/) - if (options?.bracketNotation && typeof value === 'string') { - for (const expectedValue of Object.values(schema_._def.values)) { - if (expectedValue.toString() === value) { - return expectedValue - } - } + if (match) { + const [, pattern, flags] = match + return new RegExp(pattern!, flags) } } - // - else if (typeName === ZodFirstPartyTypeKind.ZodLiteral) { - const schema_ = schema as ZodLiteral - const expectedValue = schema_._def.value - - if (typeof value === 'string' && typeof expectedValue !== 'string') { - if (typeof expectedValue === 'bigint') { - const num = guard(() => BigInt(value)) - if (num !== undefined) { - return num - } - } - else if (expectedValue === undefined) { - if (value.toLocaleLowerCase() === 'undefined') { - return undefined - } - } - else if (options?.bracketNotation) { - if (typeof expectedValue === 'number') { - const num = Number(value) - if (!Number.isNaN(num)) { - return num - } - } - else if (typeof expectedValue === 'boolean') { - const lower = value.toLowerCase() + return value +} - if (lower === 'false' || lower === 'off' || lower === 'f') { - return false - } +function safeToURL(value: string): URL | string { + return guard(() => new URL(value)) ?? value +} - if (lower === 'true' || lower === 'on' || lower === 't') { - return true - } - } - else if (expectedValue === null) { - if (value.toLocaleLowerCase() === 'null') { - return null - } - } - } +function safeToDate(value: string): Date | string { + if (value.includes('-') || value.includes(':')) { + const date = new Date(value) + if (!Number.isNaN(date.getTime())) { + return value } } - // - else { - const _expected: - | ZodFirstPartyTypeKind.ZodString - | ZodFirstPartyTypeKind.ZodEnum - | ZodFirstPartyTypeKind.ZodSymbol - | ZodFirstPartyTypeKind.ZodPromise - | ZodFirstPartyTypeKind.ZodFunction - | ZodFirstPartyTypeKind.ZodAny - | ZodFirstPartyTypeKind.ZodUnknown - | ZodFirstPartyTypeKind.ZodNever = typeName - } - return value } From ca6b46e4b8e64d51faa4bcc4662c4a4a94314e83 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 19:54:37 +0700 Subject: [PATCH 06/11] wip --- packages/zod/src/coercer.test.ts | 393 +++++++++++++++++++++++++++++++ packages/zod/src/coercer.ts | 155 ++++++------ 2 files changed, 468 insertions(+), 80 deletions(-) create mode 100644 packages/zod/src/coercer.test.ts diff --git a/packages/zod/src/coercer.test.ts b/packages/zod/src/coercer.test.ts new file mode 100644 index 000000000..ac5d45927 --- /dev/null +++ b/packages/zod/src/coercer.test.ts @@ -0,0 +1,393 @@ +import { z, type ZodTypeAny } from 'zod' +import { ZodSmartCoercionPlugin } from './coercer' +import { regexp } from './schemas/regexp' +import { url } from './schemas/url' + +type TestCase = { + schema: ZodTypeAny + input: unknown + expected: unknown +} + +enum TestEnum { + NUMBER = 123, + STRING = 'string', +} + +const nativeCases: TestCase[] = [ + { + schema: z.number(), + input: '12345', + expected: 12345, + }, + { + schema: z.number(), + input: '-12345', + expected: -12345, + }, + { + schema: z.number(), + input: '12345n', + expected: '12345n', + }, + { + schema: z.bigint(), + input: '12345', + expected: 12345n, + }, + { + schema: z.bigint(), + input: '-12345', + expected: -12345n, + }, + { + schema: z.bigint(), + input: '12345n', + expected: '12345n', + }, + { + schema: z.boolean(), + input: 't', + expected: true, + }, + { + schema: z.boolean(), + input: 'true', + expected: true, + }, + { + schema: z.boolean(), + input: 'on', + expected: true, + }, + { + schema: z.boolean(), + input: 'ON', + expected: true, + }, + { + schema: z.boolean(), + input: 'f', + expected: false, + }, + { + schema: z.boolean(), + input: 'false', + expected: false, + }, + { + schema: z.boolean(), + input: 'off', + expected: false, + }, + { + schema: z.boolean(), + input: 'OFF', + expected: false, + }, + { + schema: z.boolean(), + input: 'hi', + expected: 'hi', + }, + { + schema: z.date(), + input: new Date('2023-01-01').toISOString(), + expected: new Date('2023-01-01'), + }, + { + schema: z.date(), + input: '2023-01-01', + expected: new Date('2023-01-01'), + }, + { + schema: z.date(), + input: '2023-01-01I', + expected: '2023-01-01I', + }, + { + schema: z.literal(199n), + input: '199', + expected: BigInt(199), + }, + { + schema: z.literal(199), + input: '199', + expected: 199, + }, + { + schema: z.literal(true), + input: 't', + expected: true, + }, + { + schema: z.literal(null), + input: 'null', + expected: 'null', + }, + { + schema: z.nativeEnum(TestEnum), + input: '123', + expected: 123, + }, + { + schema: z.nativeEnum(TestEnum), + input: 'string', + expected: 'string', + }, + { + schema: z.nativeEnum(TestEnum), + input: '123n', + expected: '123n', + }, +] + +const combinationCases: TestCase[] = [ + { + schema: z.union([z.boolean(), z.number()]), + input: '123', + expected: 123, + }, + { + schema: z.union([z.boolean(), z.number()]), + input: 'true', + expected: true, + }, + { + schema: z.union([z.boolean(), z.number()]), + input: 'INVALID', + expected: 'INVALID', + }, + { + schema: z.union([] as any), + input: 'INVALID', + expected: 'INVALID', + }, + { + schema: z.object({ a: z.number() }).and(z.object({ b: z.boolean() })), + input: { a: '1234', b: 'true' }, + expected: { a: 1234, b: true }, + }, + { + schema: z.boolean().readonly(), + input: 'true', + expected: true, + }, + { + schema: z.boolean().pipe(z.string()), + input: 'true', + expected: true, + }, + { + schema: z.boolean().transform(() => {}), + input: 'true', + expected: true, + }, + { + schema: z.boolean().brand<'CAT'>(), + input: 'true', + expected: true, + }, + { + schema: z.boolean().catch(false), + input: 'true', + expected: true, + }, + { + schema: z.boolean().default(false), + input: 'true', + expected: true, + }, + { + schema: z.boolean().nullable(), + input: 'true', + expected: true, + }, + { + schema: z.boolean().nullable(), + input: null, + expected: null, + }, + { + schema: z.boolean().optional(), + input: 'true', + expected: true, + }, + { + schema: z.boolean().optional(), + input: undefined, + expected: undefined, + }, + { + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { value: 'true' } }, + expected: { value: { value: true } }, + }, + { + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: { value: { } }, + expected: { value: { } }, + }, + { + schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })), + input: undefined, + expected: undefined, + }, +] + +const customSchemaCases: TestCase[] = [ + { + schema: url(), + input: 'https://www.google.com', + expected: new URL('https://www.google.com'), + }, + { + schema: url(), + input: 'INVALID', + expected: 'INVALID', + }, + { + schema: regexp(), + input: '/abcd/i', + expected: /abcd/i, + }, + { + schema: regexp(), + input: '/invalid', + expected: '/invalid', + }, +] + +const notCoerceCases: TestCase[] = [ + { + schema: z.number().or(z.string()), + input: '123', + expected: '123', + }, + { + schema: z.boolean().or(z.string()), + input: 'true', + expected: 'true', + }, +] + +describe.each([ + ...nativeCases, + ...combinationCases, + ...customSchemaCases, + ...notCoerceCases, +])('zodSmartCoercionPlugin: %#', ({ schema, input, expected }) => { + const plugin = new ZodSmartCoercionPlugin() + const options = {} as any + plugin.init(options) + + const coerce = (schema: ZodTypeAny, input: unknown) => { + let coerced: unknown + + options.clientInterceptors[0]({ + procedure: { + '~orpc': { + inputSchema: schema, + }, + }, + input, + next: ({ input }: any) => { + coerced = input + }, + }) + + return coerced + } + + it('flat', () => { + expect(coerce(schema, input)).toEqual(expected) + expect(coerce(schema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('object', () => { + const testSchema = z.object({ a: schema, b: schema }) + expect(coerce(testSchema, { a: input, b: input, c: input })).toEqual({ a: expected, b: expected, c: input }) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('object missing', () => { + const testSchema = z.object({ a: schema, b: schema }) + expect(coerce(testSchema, { a: input })).toEqual({ a: expected }) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('object with catchall', () => { + const testSchema = z.object({ a: schema, b: schema }).catchall(schema) + expect(coerce(testSchema, { a: input, b: input, c: input })).toEqual({ a: expected, b: expected, c: expected }) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('record', () => { + const testSchema = z.record(schema) + expect(coerce(testSchema, { a: input, b: input, c: input })).toEqual({ a: expected, b: expected, c: expected }) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('array', () => { + const testSchema = z.array(schema) + expect(coerce(testSchema, [input, input])).toEqual([expected, expected]) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('tuple', () => { + const testSchema = z.tuple([schema, schema]) + expect(coerce(testSchema, [input, input, input])).toEqual([expected, expected, input]) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('tuple with rest', () => { + const testSchema = z.tuple([schema, schema]).rest(schema) + expect(coerce(testSchema, [input, input, input])).toEqual([expected, expected, expected]) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('set', () => { + const testSchema = z.set(schema) + expect(coerce(testSchema, [input])).toEqual(new Set([expected])) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) + + it('map', () => { + const testSchema = z.map(schema, schema) + expect(coerce(testSchema, [[input, '__VALUE__'], ['__KEY__', input]])).toEqual(new Map([[expected, '__VALUE__'], ['__KEY__', expected]])) + expect(coerce(testSchema, '__INVALID__')).toEqual('__INVALID__') + }) +}) + +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/coercer.ts b/packages/zod/src/coercer.ts index 239d2e004..6802f6f6d 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -106,24 +106,38 @@ function zodCoerceInternal( return value } - case ZodFirstPartyTypeKind.ZodArray: { - const schema_ = schema as ZodArray + case ZodFirstPartyTypeKind.ZodLiteral: { + const schema_ = schema as ZodLiteral + const expectedValue = schema_._def.value - if (Array.isArray(value)) { - return value.map(v => zodCoerceInternal(schema_._def.type, v)) + if (typeof value === 'string' && typeof expectedValue !== 'string') { + if (typeof expectedValue === 'bigint') { + return safeToBigInt(value) + } + else if (typeof expectedValue === 'number') { + return safeToNumber(value) + } + else if (typeof expectedValue === 'boolean') { + return safeToBoolean(value) + } } return value } - case ZodFirstPartyTypeKind.ZodTuple: { - const schema_ = schema as ZodTuple<[ZodTypeAny, ...ZodTypeAny[]], ZodTypeAny | null> + case ZodFirstPartyTypeKind.ZodNativeEnum: { + const schema_ = schema as ZodNativeEnum - if (Array.isArray(value)) { - return value.map((v, i) => { - const s = schema_._def.items[i] ?? schema_._def.rest - return s ? zodCoerceInternal(s, v) : v - }) + if (Object.values(schema_._def.values).includes(value as any)) { + return value + } + + if (typeof value === 'string') { + for (const expectedValue of Object.values(schema_._def.values)) { + if (expectedValue.toString() === value) { + return expectedValue + } + } } return value @@ -153,6 +167,47 @@ function zodCoerceInternal( return value } + case ZodFirstPartyTypeKind.ZodRecord: { + const schema_ = schema as ZodRecord + + if (isObject(value)) { + const newObj: any = {} + + for (const [k, v] of Object.entries(value)) { + const key = zodCoerceInternal(schema_._def.keyType, k) + const val = zodCoerceInternal(schema_._def.valueType, v) + newObj[key as any] = val + } + + return newObj + } + + return value + } + + case ZodFirstPartyTypeKind.ZodArray: { + const schema_ = schema as ZodArray + + if (Array.isArray(value)) { + return value.map(v => zodCoerceInternal(schema_._def.type, v)) + } + + return value + } + + case ZodFirstPartyTypeKind.ZodTuple: { + const schema_ = schema as ZodTuple<[ZodTypeAny, ...ZodTypeAny[]], ZodTypeAny | null> + + if (Array.isArray(value)) { + return value.map((v, i) => { + const s = schema_._def.items[i] ?? schema_._def.rest + return s ? zodCoerceInternal(s, v) : v + }) + } + + return value + } + case ZodFirstPartyTypeKind.ZodSet: { const schema_ = schema as ZodSet @@ -183,24 +238,6 @@ function zodCoerceInternal( return value } - case ZodFirstPartyTypeKind.ZodRecord: { - const schema_ = schema as ZodRecord - - if (isObject(value)) { - const newObj: any = {} - - for (const [k, v] of Object.entries(value)) { - const key = zodCoerceInternal(schema_._def.keyType, k) - const val = zodCoerceInternal(schema_._def.valueType, v) - newObj[key as any] = val - } - - return newObj - } - - return value - } - case ZodFirstPartyTypeKind.ZodUnion: case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: { const schema_ = schema as @@ -215,10 +252,6 @@ function zodCoerceInternal( for (const s of schema_._def.options) { const newValue = zodCoerceInternal(s, value) - if (newValue === value) { - continue - } - const result = schema_.safeParse(newValue) if (result.success) { @@ -254,16 +287,6 @@ function zodCoerceInternal( return zodCoerceInternal(schema_._def.in, value) } - case ZodFirstPartyTypeKind.ZodLazy: { - const schema_ = schema as ZodLazy - - if (value !== undefined) { - return zodCoerceInternal(schema_._def.getter(), value) - } - - return value - } - case ZodFirstPartyTypeKind.ZodEffects: { const schema_ = schema as ZodEffects return zodCoerceInternal(schema_._def.schema, value) @@ -302,38 +325,11 @@ function zodCoerceInternal( return zodCoerceInternal(schema_._def.innerType, value) } - case ZodFirstPartyTypeKind.ZodNativeEnum: { - const schema_ = schema as ZodNativeEnum - - if (Object.values(schema_._def.values).includes(value as any)) { - return value - } - - if (typeof value === 'string') { - for (const expectedValue of Object.values(schema_._def.values)) { - if (expectedValue.toString() === value) { - return expectedValue - } - } - } - - return value - } - - case ZodFirstPartyTypeKind.ZodLiteral: { - const schema_ = schema as ZodLiteral - const expectedValue = schema_._def.value + case ZodFirstPartyTypeKind.ZodLazy: { + const schema_ = schema as ZodLazy - if (typeof value === 'string' && typeof expectedValue !== 'string') { - if (typeof expectedValue === 'bigint') { - return safeToBigInt(value) - } - else if (typeof expectedValue === 'number') { - return safeToNumber(value) - } - else if (typeof expectedValue === 'boolean') { - return safeToBoolean(value) - } + if (value !== undefined) { + return zodCoerceInternal(schema_._def.getter(), value) } return value @@ -344,8 +340,8 @@ function zodCoerceInternal( | undefined | ZodFirstPartyTypeKind.ZodUndefined | ZodFirstPartyTypeKind.ZodVoid - | ZodFirstPartyTypeKind.ZodNaN | ZodFirstPartyTypeKind.ZodNull + | ZodFirstPartyTypeKind.ZodNaN | ZodFirstPartyTypeKind.ZodString | ZodFirstPartyTypeKind.ZodEnum | ZodFirstPartyTypeKind.ZodSymbol @@ -399,11 +395,10 @@ function safeToURL(value: string): URL | string { } function safeToDate(value: string): Date | string { - if (value.includes('-') || value.includes(':')) { - const date = new Date(value) - if (!Number.isNaN(date.getTime())) { - return value - } + const date = new Date(value) + + if (!Number.isNaN(date.getTime()) && date.toISOString().startsWith(value)) { + return date } return value From 028a88348e5702c39c477db5c662a6004729951d Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 20:03:20 +0700 Subject: [PATCH 07/11] fix docs --- apps/content/docs/openapi/plugins/zod-smart-coercion.md | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/content/docs/openapi/plugins/zod-smart-coercion.md b/apps/content/docs/openapi/plugins/zod-smart-coercion.md index 28ab03523..a5a8bcd2c 100644 --- a/apps/content/docs/openapi/plugins/zod-smart-coercion.md +++ b/apps/content/docs/openapi/plugins/zod-smart-coercion.md @@ -107,7 +107,6 @@ Supported formats: - Full ISO date-time (e.g., `2024-11-27T00:00:00.000Z`) - Date only (e.g., `2024-11-27`) -- Time only (e.g., `19:00:00`) #### RegExp From fcabbe888e23a05bb4da5e1cf27a353b8115fc87 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 20:28:18 +0700 Subject: [PATCH 08/11] wip --- eslint.config.js | 6 ++++ packages/openapi/src/schema-converter.ts | 28 +++++++----------- packages/openapi/src/schema.ts | 14 +++++---- packages/zod/package.json | 4 +-- packages/zod/src/converter.ts | 37 ++++++++++++++++++++++-- packages/zod/src/custom-json-schema.ts | 2 +- packages/zod/src/index.ts | 1 + pnpm-lock.yaml | 6 ---- 8 files changed, 62 insertions(+), 36 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index bfb3fa63d..a2f06c6a0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,12 @@ export default antfu({ message: 'JSON.stringify can return undefined, use stringifyJSON instead', }, ], + 'no-restricted-imports': ['error', { + patterns: [{ + group: ['json-schema-typed', 'json-schema-typed/*'], + message: 'Please import from @orpc/openapi instead', + }], + }], }, }, { files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test-d.ts', '**/*.test-d.tsx', 'apps/content/shared/**', 'playgrounds/**', 'packages/*/playground/**'], diff --git a/packages/openapi/src/schema-converter.ts b/packages/openapi/src/schema-converter.ts index e78f379d7..7f6ace738 100644 --- a/packages/openapi/src/schema-converter.ts +++ b/packages/openapi/src/schema-converter.ts @@ -1,34 +1,28 @@ import type { Schema } from '@orpc/contract' import type { JSONSchema } from './schema' -export interface SchemaConvertOptions { - strategy: 'input' | 'output' -} +export type SchemaConvertStrategy = 'input' | 'output' -export interface SchemaConverter { - condition(schema: Schema, options: SchemaConvertOptions): boolean +export interface ConditionalSchemaConverter { + condition(schema: Schema, strategy: SchemaConvertStrategy): boolean - convert(schema: Schema, options: SchemaConvertOptions): JSONSchema.JSONSchema + convert(schema: Schema, strategy: SchemaConvertStrategy): [required: boolean, jsonSchema: JSONSchema] } -export class CompositeSchemaConverter implements SchemaConverter { - private readonly converters: SchemaConverter[] +export class CompositeSchemaConverter { + private readonly converters: ConditionalSchemaConverter[] - constructor(converters: SchemaConverter[]) { + constructor(converters: ConditionalSchemaConverter[]) { this.converters = converters } - condition(): boolean { - return true - } - - convert(schema: Schema, options: SchemaConvertOptions): JSONSchema.JSONSchema { + convert(schema: Schema, strategy: SchemaConvertStrategy): [required: boolean, jsonSchema: JSONSchema] { for (const converter of this.converters) { - if (converter.condition(schema, options)) { - return converter.convert(schema, options) + if (converter.condition(schema, strategy)) { + return converter.convert(schema, strategy) } } - return {} // ANY SCHEMA + return [false, {}] } } diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts index 877ee52f4..794cfee1d 100644 --- a/packages/openapi/src/schema.ts +++ b/packages/openapi/src/schema.ts @@ -1,10 +1,12 @@ -import * as JSONSchema from 'json-schema-typed/draft-2020-12' +/* eslint-disable no-restricted-imports */ +import type { JSONSchema } from 'json-schema-typed/draft-2020-12' +import { Format as JSONSchemaFormat, keywords as JSONSchemaKeywords } from 'json-schema-typed/draft-2020-12' -export { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' -export { JSONSchema } +export { JSONSchemaFormat, JSONSchemaKeywords } +export type { JSONSchema } -export type ObjectSchema = JSONSchema.JSONSchema & { type: 'object' } & object -export type FileSchema = JSONSchema.JSONSchema & { type: 'string', contentMediaType: string } & object +export type ObjectSchema = JSONSchema & { type: 'object' } & object +export type FileSchema = JSONSchema & { type: 'string', contentMediaType: string } & object export const NON_LOGIC_KEYWORDS: string[] = [ // Core Documentation Keywords @@ -35,4 +37,4 @@ export const NON_LOGIC_KEYWORDS: string[] = [ '$vocabulary', '$dynamicAnchor', '$dynamicRef', -] satisfies (typeof JSONSchema.keywords)[number][] +] satisfies (typeof JSONSchemaKeywords)[number][] diff --git a/packages/zod/package.json b/packages/zod/package.json index b13f0970f..8e9d59bc8 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -35,14 +35,12 @@ }, "peerDependencies": { "@orpc/contract": "workspace:*", - "@orpc/openapi": "workspace:*", "@orpc/server": "workspace:*" }, "dependencies": { + "@orpc/openapi": "workspace:*", "@orpc/shared": "workspace:*", - "@standard-schema/spec": "^1.0.0", "escape-string-regexp": "^5.0.0", - "json-schema-typed": "^8.0.1", "wildcard-match": "^5.1.3", "zod": "^3.24.2" }, diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 31758f474..206758d0c 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -1,6 +1,37 @@ import type { Schema } from '@orpc/contract' -import type { JSONSchema } from 'json-schema-typed/draft-2020-12' -import type { EnumLike, KeySchema, ZodAny, ZodArray, ZodBranded, ZodCatch, ZodDefault, ZodDiscriminatedUnion, ZodEffects, ZodEnum, ZodIntersection, ZodLazy, ZodLiteral, ZodMap, ZodNativeEnum, ZodNullable, ZodNumber, ZodObject, ZodOptional, ZodPipeline, ZodRawShape, ZodReadonly, ZodRecord, ZodSet, ZodString, ZodTuple, ZodTypeAny, ZodTypeDef, ZodUnion, ZodUnionOptions } from 'zod' +import type { ConditionalSchemaConverter, JSONSchema } from '@orpc/openapi' +import type { + EnumLike, + KeySchema, + ZodAny, + ZodArray, + ZodBranded, + ZodCatch, + ZodDefault, + ZodDiscriminatedUnion, + ZodEffects, + ZodEnum, + ZodIntersection, + ZodLazy, + ZodLiteral, + ZodMap, + ZodNativeEnum, + ZodNullable, + ZodNumber, + ZodObject, + ZodOptional, + ZodPipeline, + ZodRawShape, + ZodReadonly, + ZodRecord, + ZodSet, + ZodString, + ZodTuple, + ZodTypeAny, + ZodTypeDef, + ZodUnion, + ZodUnionOptions, +} from 'zod' import { JSONSchemaFormat } from '@orpc/openapi' import escapeStringRegexp from 'escape-string-regexp' import { ZodFirstPartyTypeKind } from 'zod' @@ -32,7 +63,7 @@ export interface ZodToJsonSchemaOptions { anyJsonSchema?: Exclude } -export class ZodToJsonSchemaConverter { +export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude private readonly unsupportedJsonSchema: Exclude private readonly anyJsonSchema: Exclude diff --git a/packages/zod/src/custom-json-schema.ts b/packages/zod/src/custom-json-schema.ts index 419489b89..57af5ca06 100644 --- a/packages/zod/src/custom-json-schema.ts +++ b/packages/zod/src/custom-json-schema.ts @@ -1,4 +1,4 @@ -import type { JSONSchema } from 'json-schema-typed/draft-2020-12' +import type { JSONSchema } from '@orpc/openapi' import type { input, output, ZodTypeAny, ZodTypeDef } from 'zod' const CUSTOM_JSON_SCHEMA_SYMBOL = Symbol('ORPC_CUSTOM_JSON_SCHEMA') diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 0e807e2ee..8f613e5b4 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -4,6 +4,7 @@ import { file } from './schemas/file' import { regexp } from './schemas/regexp' import { url } from './schemas/url' +export * from './coercer' export * from './converter' export * from './custom-json-schema' export * from './schemas/base' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b37dda3c2..374b43733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,15 +365,9 @@ importers: '@orpc/shared': specifier: workspace:* version: link:../shared - '@standard-schema/spec': - specifier: ^1.0.0 - version: 1.0.0 escape-string-regexp: specifier: ^5.0.0 version: 5.0.0 - json-schema-typed: - specifier: ^8.0.1 - version: 8.0.1 wildcard-match: specifier: ^5.1.3 version: 5.1.4 From 2050d55f06472cf127b647e1443fb1848cd15dad Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 20:49:27 +0700 Subject: [PATCH 09/11] sync --- .../openapi/src/openapi-content-builder.ts | 4 +- .../openapi/src/openapi-generator.test.ts | 16 +-- packages/openapi/src/openapi-generator.ts | 18 +-- .../src/openapi-input-structure-parser.ts | 10 +- .../src/openapi-output-structure-parser.ts | 8 +- .../openapi/src/openapi-parameters-builder.ts | 4 +- packages/openapi/src/schema-converter.ts | 10 +- packages/openapi/src/schema-utils.ts | 20 +-- packages/zod/tests/coercer.test.ts | 49 -------- packages/zod/tests/custom.test.ts | 118 ------------------ packages/zod/tests/openapi.test-d.ts | 67 ---------- packages/zod/tests/openapi.test.ts | 70 ----------- 12 files changed, 46 insertions(+), 348 deletions(-) delete mode 100644 packages/zod/tests/coercer.test.ts delete mode 100644 packages/zod/tests/custom.test.ts delete mode 100644 packages/zod/tests/openapi.test-d.ts delete mode 100644 packages/zod/tests/openapi.test.ts diff --git a/packages/openapi/src/openapi-content-builder.ts b/packages/openapi/src/openapi-content-builder.ts index 41f305106..ce0d7f97b 100644 --- a/packages/openapi/src/openapi-content-builder.ts +++ b/packages/openapi/src/openapi-content-builder.ts @@ -8,12 +8,12 @@ export class OpenAPIContentBuilder { private readonly schemaUtils: PublicSchemaUtils, ) {} - build(jsonSchema: JSONSchema.JSONSchema, options?: Partial): OpenAPI.ContentObject { + build(jsonSchema: JSONSchema, options?: Partial): OpenAPI.ContentObject { const isFileSchema = this.schemaUtils.isFileSchema.bind(this.schemaUtils) const [matches, schema] = this.schemaUtils.filterSchemaBranches(jsonSchema, isFileSchema) - const files = matches as (JSONSchema.JSONSchema & { + const files = matches as (JSONSchema & { type: 'string' contentMediaType: string })[] diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index f6177eb8c..4fa58edec 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -26,7 +26,7 @@ describe('openapi generator', () => { .input(defaultSchema) .output(defaultSchema) - mockConverter.convert.mockReturnValue({ + mockConverter.convert.mockReturnValue([true, { type: 'object', properties: { name: { @@ -36,7 +36,7 @@ describe('openapi generator', () => { type: 'string', }, }, - }) + }]) const spec = await generator.generate(router, defaultDoc) @@ -100,7 +100,7 @@ describe('openapi generator', () => { mockConverter.convert.mockImplementation((schema) => { if (schema === inputSchema) { - return { + return [true, { type: 'object', properties: { params: { @@ -147,10 +147,10 @@ describe('openapi generator', () => { }, }, }, - } satisfies JSONSchema.JSONSchema + } satisfies JSONSchema] } - return { + return [true, { type: 'object', properties: { headers: { @@ -170,7 +170,7 @@ describe('openapi generator', () => { }, }, }, - } + }] }) const spec = await generator.generate(router, defaultDoc) @@ -269,7 +269,7 @@ describe('openapi generator', () => { }).route({}) it('strictErrorResponses=true', async () => { - mockConverter.convert.mockReturnValue({ description: '__mocked__' }) + mockConverter.convert.mockReturnValue([true, { description: '__mocked__' }]) const spec = await generator.generate(contract, defaultDoc) @@ -324,7 +324,7 @@ describe('openapi generator', () => { strictErrorResponses: false, }) - mockConverter.convert.mockReturnValue({ description: '__mocked__' }) + mockConverter.convert.mockReturnValue([true, { description: '__mocked__' }]) const spec = await generator.generate(contract, defaultDoc) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index f90d43012..0e3fc4e93 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -2,7 +2,7 @@ import type { PublicOpenAPIInputStructureParser } from './openapi-input-structur import type { PublicOpenAPIOutputStructureParser } from './openapi-output-structure-parser' import type { PublicOpenAPIPathParser } from './openapi-path-parser' import type { JSONSchema } from './schema' -import type { SchemaConverter } from './schema-converter' +import type { ConditionalSchemaConverter } from './schema-converter' import { fallbackORPCErrorStatus } from '@orpc/client' import { type ContractRouter, fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract' import { OpenAPIJsonSerializer } from '@orpc/openapi-client/standard' @@ -25,7 +25,7 @@ type ErrorHandlerStrategy = 'throw' | 'log' | 'ignore' export interface OpenAPIGeneratorOptions { contentBuilder?: PublicOpenAPIContentBuilder parametersBuilder?: PublicOpenAPIParametersBuilder - schemaConverters?: SchemaConverter[] + schemaConverters?: ConditionalSchemaConverter[] schemaUtils?: PublicSchemaUtils jsonSerializer?: OpenAPIJsonSerializer pathParser?: PublicOpenAPIPathParser @@ -132,7 +132,7 @@ export class OpenAPIGenerator { type: 'object', properties: { event: { type: 'string', const: 'message' }, - data: this.schemaConverter.convert(eventIteratorSchemaDetails.yields, { strategy: 'input' }) as any, + data: this.schemaConverter.convert(eventIteratorSchemaDetails.yields, 'input')[1] as any, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -142,7 +142,7 @@ export class OpenAPIGenerator { type: 'object', properties: { event: { type: 'string', const: 'done' }, - data: this.schemaConverter.convert(eventIteratorSchemaDetails.returns, { strategy: 'input' }) as any, + data: this.schemaConverter.convert(eventIteratorSchemaDetails.returns, 'input')[1] as any, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -213,7 +213,7 @@ export class OpenAPIGenerator { type: 'object', properties: { event: { type: 'string', const: 'message' }, - data: this.schemaConverter.convert(eventIteratorSchemaDetails.yields, { strategy: 'input' }) as any, + data: this.schemaConverter.convert(eventIteratorSchemaDetails.yields, 'input')[1] as any, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -223,7 +223,7 @@ export class OpenAPIGenerator { type: 'object', properties: { event: { type: 'string', const: 'done' }, - data: this.schemaConverter.convert(eventIteratorSchemaDetails.returns, { strategy: 'input' }) as any, + data: this.schemaConverter.convert(eventIteratorSchemaDetails.returns, 'input')[1] as any, id: { type: 'string' }, retry: { type: 'number' }, }, @@ -281,7 +281,7 @@ export class OpenAPIGenerator { continue } - const schemas: JSONSchema.JSONSchema[] = configs + const schemas: JSONSchema[] = configs .map(({ data, code, message }) => { const json = { type: 'object', @@ -293,10 +293,10 @@ export class OpenAPIGenerator { data: {}, }, required: ['defined', 'code', 'status', 'message'], - } satisfies JSONSchema.JSONSchema + } satisfies JSONSchema if (data) { - const dataJson = this.schemaConverter.convert(data, { strategy: 'output' }) + const dataJson = this.schemaConverter.convert(data, 'output')[1] json.properties.data = dataJson diff --git a/packages/openapi/src/openapi-input-structure-parser.ts b/packages/openapi/src/openapi-input-structure-parser.ts index 95d663a0f..0db980243 100644 --- a/packages/openapi/src/openapi-input-structure-parser.ts +++ b/packages/openapi/src/openapi-input-structure-parser.ts @@ -10,7 +10,7 @@ export interface OpenAPIInputStructureParseResult { paramsSchema: ObjectSchema | undefined querySchema: ObjectSchema | undefined headersSchema: ObjectSchema | undefined - bodySchema: JSONSchema.JSONSchema | undefined + bodySchema: JSONSchema | undefined } export class OpenAPIInputStructureParser { @@ -21,7 +21,7 @@ export class OpenAPIInputStructureParser { ) { } parse(contract: AnyContractProcedure, structure: 'compact' | 'detailed'): OpenAPIInputStructureParseResult { - const inputSchema = this.schemaConverter.convert(contract['~orpc'].inputSchema, { strategy: 'input' }) + const [_, inputSchema] = this.schemaConverter.convert(contract['~orpc'].inputSchema, 'input') const method = fallbackContractConfig('defaultMethod', contract['~orpc'].route?.method) const httpPath = contract['~orpc'].route?.path @@ -42,7 +42,7 @@ export class OpenAPIInputStructureParser { } } - private parseDetailedSchema(inputSchema: JSONSchema.JSONSchema): OpenAPIInputStructureParseResult { + private parseDetailedSchema(inputSchema: JSONSchema): OpenAPIInputStructureParseResult { if (!this.schemaUtils.isObjectSchema(inputSchema)) { throw new OpenAPIError(`When input structure is 'detailed', input schema must be an object.`) } @@ -83,12 +83,12 @@ export class OpenAPIInputStructureParser { return { paramsSchema, querySchema, headersSchema, bodySchema } } - private parseCompactSchema(inputSchema: JSONSchema.JSONSchema, method: string, httpPath: string | undefined): OpenAPIInputStructureParseResult { + private parseCompactSchema(inputSchema: JSONSchema, method: string, httpPath: string | undefined): OpenAPIInputStructureParseResult { const dynamic = httpPath ? this.pathParser.parseDynamicParams(httpPath) : [] if (dynamic.length === 0) { if (method === 'GET') { - let querySchema: JSONSchema.JSONSchema | undefined = inputSchema + let querySchema: JSONSchema | undefined = inputSchema if (querySchema !== undefined && this.schemaUtils.isAnySchema(querySchema)) { querySchema = undefined diff --git a/packages/openapi/src/openapi-output-structure-parser.ts b/packages/openapi/src/openapi-output-structure-parser.ts index 7f1a9a364..5917e1572 100644 --- a/packages/openapi/src/openapi-output-structure-parser.ts +++ b/packages/openapi/src/openapi-output-structure-parser.ts @@ -6,7 +6,7 @@ import { OpenAPIError } from './openapi-error' export interface OpenAPIOutputStructureParseResult { headersSchema: ObjectSchema | undefined - bodySchema: JSONSchema.JSONSchema | undefined + bodySchema: JSONSchema | undefined } export class OpenAPIOutputStructureParser { @@ -16,7 +16,7 @@ export class OpenAPIOutputStructureParser { ) { } parse(contract: AnyContractProcedure, structure: 'compact' | 'detailed'): OpenAPIOutputStructureParseResult { - const outputSchema = this.schemaConverter.convert(contract['~orpc'].outputSchema, { strategy: 'output' }) + const [_, outputSchema] = this.schemaConverter.convert(contract['~orpc'].outputSchema, 'output') // TODO: refactor and remove this logic if (this.schemaUtils.isAnySchema(outputSchema)) { @@ -34,7 +34,7 @@ export class OpenAPIOutputStructureParser { } } - private parseDetailedSchema(outputSchema: JSONSchema.JSONSchema): OpenAPIOutputStructureParseResult { + private parseDetailedSchema(outputSchema: JSONSchema): OpenAPIOutputStructureParseResult { if (!this.schemaUtils.isObjectSchema(outputSchema)) { throw new OpenAPIError(`When output structure is 'detailed', output schema must be an object.`) } @@ -57,7 +57,7 @@ export class OpenAPIOutputStructureParser { return { headersSchema, bodySchema } } - private parseCompactSchema(outputSchema: JSONSchema.JSONSchema): OpenAPIOutputStructureParseResult { + private parseCompactSchema(outputSchema: JSONSchema): OpenAPIOutputStructureParseResult { return { headersSchema: undefined, bodySchema: outputSchema, diff --git a/packages/openapi/src/openapi-parameters-builder.ts b/packages/openapi/src/openapi-parameters-builder.ts index 7e12c7400..f4bb8c6ce 100644 --- a/packages/openapi/src/openapi-parameters-builder.ts +++ b/packages/openapi/src/openapi-parameters-builder.ts @@ -5,7 +5,7 @@ import { get, isObject, omit } from '@orpc/shared' export class OpenAPIParametersBuilder { build( paramIn: OpenAPI.ParameterObject['in'], - jsonSchema: JSONSchema.JSONSchema & { type: 'object' } & object, + jsonSchema: JSONSchema & { type: 'object' } & object, options?: Pick, ): OpenAPI.ParameterObject[] { const parameters: OpenAPI.ParameterObject[] = [] @@ -46,7 +46,7 @@ export class OpenAPIParametersBuilder { } buildHeadersObject( - jsonSchema: JSONSchema.JSONSchema & { type: 'object' } & object, + jsonSchema: JSONSchema & { type: 'object' } & object, options?: Pick, ): OpenAPI.HeadersObject { const parameters = this.build('header', jsonSchema, options) diff --git a/packages/openapi/src/schema-converter.ts b/packages/openapi/src/schema-converter.ts index 7f6ace738..56c635f17 100644 --- a/packages/openapi/src/schema-converter.ts +++ b/packages/openapi/src/schema-converter.ts @@ -3,13 +3,15 @@ import type { JSONSchema } from './schema' export type SchemaConvertStrategy = 'input' | 'output' -export interface ConditionalSchemaConverter { - condition(schema: Schema, strategy: SchemaConvertStrategy): boolean - +export interface SchemaConverter { convert(schema: Schema, strategy: SchemaConvertStrategy): [required: boolean, jsonSchema: JSONSchema] } -export class CompositeSchemaConverter { +export interface ConditionalSchemaConverter extends SchemaConverter { + condition(schema: Schema, strategy: SchemaConvertStrategy): boolean +} + +export class CompositeSchemaConverter implements SchemaConverter { private readonly converters: ConditionalSchemaConverter[] constructor(converters: ConditionalSchemaConverter[]) { diff --git a/packages/openapi/src/schema-utils.ts b/packages/openapi/src/schema-utils.ts index 87e20728a..9e14883ce 100644 --- a/packages/openapi/src/schema-utils.ts +++ b/packages/openapi/src/schema-utils.ts @@ -2,19 +2,19 @@ import { isObject } from '@orpc/shared' import { type FileSchema, type JSONSchema, NON_LOGIC_KEYWORDS, type ObjectSchema } from './schema' export class SchemaUtils { - isFileSchema(schema: JSONSchema.JSONSchema): schema is FileSchema { + isFileSchema(schema: JSONSchema): schema is FileSchema { return isObject(schema) && schema.type === 'string' && typeof schema.contentMediaType === 'string' } - isObjectSchema(schema: JSONSchema.JSONSchema): schema is ObjectSchema { + isObjectSchema(schema: JSONSchema): schema is ObjectSchema { return isObject(schema) && schema.type === 'object' } - isAnySchema(schema: JSONSchema.JSONSchema): boolean { + isAnySchema(schema: JSONSchema): boolean { return schema === true || Object.keys(schema).filter(key => !NON_LOGIC_KEYWORDS.includes(key)).length === 0 } - isUndefinableSchema(schema: JSONSchema.JSONSchema): boolean { + isUndefinableSchema(schema: JSONSchema): boolean { const [matches] = this.filterSchemaBranches(schema, (schema) => { if (typeof schema === 'boolean') { return schema @@ -35,7 +35,7 @@ export class SchemaUtils { .reduce((acc, [key, value]) => { acc[key] = value return acc - }, {} as Record) + }, {} as Record) matched.required = schema.required?.filter(key => separatedProperties.includes(key)) @@ -58,7 +58,7 @@ export class SchemaUtils { .reduce((acc, [key, value]) => { acc[key] = value return acc - }, {} as Record) + }, {} as Record) rest.required = schema.required?.filter(key => !separatedProperties.includes(key)) @@ -80,10 +80,10 @@ export class SchemaUtils { } filterSchemaBranches( - schema: JSONSchema.JSONSchema, - check: (schema: JSONSchema.JSONSchema) => boolean, - matches: JSONSchema.JSONSchema[] = [], - ): [matches: JSONSchema.JSONSchema[], rest: JSONSchema.JSONSchema | undefined] { + schema: JSONSchema, + check: (schema: JSONSchema) => boolean, + matches: JSONSchema[] = [], + ): [matches: JSONSchema[], rest: JSONSchema | undefined] { if (check(schema)) { matches.push(schema) return [matches, undefined] diff --git a/packages/zod/tests/coercer.test.ts b/packages/zod/tests/coercer.test.ts deleted file mode 100644 index 6bafd6ada..000000000 --- a/packages/zod/tests/coercer.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { OpenAPIHandler } from '@orpc/openapi/fetch' -import { os } from '@orpc/server' -import { z } from 'zod' -import { ZodSmartCoercionPlugin } from '../src' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('zodSmartCoercionPlugin', () => { - it('should coerce input', async () => { - const fn = vi.fn().mockReturnValue('__mocked__') - - const router = os.router({ - ping: os - .route({ path: '/ping/{id}', inputStructure: 'detailed' }) - .input(z.object({ - params: z.object({ - id: z.number(), - }), - body: z.object({ val: z.bigint() }), - })) - .handler(fn), - }) - - const handler = new OpenAPIHandler(router, { - plugins: [ - new ZodSmartCoercionPlugin(), - ], - }) - const { response } = await handler.handle(new Request('https://example.com/ping/12345', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - val: '123', - }), - })) - - expect(response?.status).toBe(200) - expect(fn).toHaveBeenCalledWith(expect.objectContaining({ - input: { - params: { id: 12345 }, - body: { val: 123n }, - }, - })) - }) -}) diff --git a/packages/zod/tests/custom.test.ts b/packages/zod/tests/custom.test.ts deleted file mode 100644 index 01f16f613..000000000 --- a/packages/zod/tests/custom.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { getCustomZodFileMimeType, getCustomZodType, oz } from '../src' - -const testFile = new File([], 'test.txt') -const textFile = new File([], 'test.txt', { type: 'text/plain' }) -const imageFile = new File([], 'test.jpg', { type: 'image/jpeg' }) -const testBlob = new Blob(['hello world'], { type: 'text/plain' }) -const invalidDate = new Date('invalid date') -const validDate = new Date() -const testRegex = /test/ -const testUrl = new URL('https://example.com') - -const cases = [ - // File tests - ['File', oz.file(), testFile, undefined, undefined], - ['File', oz.file('Custom error'), 'not a file', undefined, 'Custom error'], - ['File', oz.file().type('text/plain'), textFile, 'text/plain', undefined], - [ - 'File', - oz.file().type('text/plain'), - testFile, - 'text/plain', - 'Expected a file of type text/plain but got a file of type unknown', - ], - [ - 'File', - oz.file().type('text/plain'), - imageFile, - 'text/plain', - 'Expected a file of type text/plain but got a file of type image/jpeg', - ], - ['File', oz.file().type('image/*'), imageFile, 'image/*', undefined], - [ - 'File', - oz.file().type('image/*'), - textFile, - 'image/*', - 'Expected a file of type image/* but got a file of type text/plain', - ], - [ - 'File', - oz.file().type('text/plain', 'Custom mime type error'), - imageFile, - 'text/plain', - 'Custom mime type error', - ], - - // Blob tests - ['Blob', oz.blob(), testBlob, undefined, undefined], - ['Blob', oz.blob(), 'not a blob', undefined, 'Input is not a blob'], - [ - 'Blob', - oz.blob('Custom blob error'), - 'not a blob', - undefined, - 'Custom blob error', - ], - - // Invalid Date tests - ['Invalid Date', oz.invalidDate(), invalidDate, undefined, undefined], - [ - 'Invalid Date', - oz.invalidDate(), - validDate, - undefined, - 'Input is not an invalid date', - ], - [ - 'Invalid Date', - oz.invalidDate('Custom date error'), - validDate, - undefined, - 'Custom date error', - ], - - // RegExp tests - ['RegExp', oz.regexp(), testRegex, undefined, undefined], - ['RegExp', oz.regexp(), 'not a regex', undefined, 'Input is not a regexp'], - [ - 'RegExp', - oz.regexp({ message: 'Custom regex error' }), - 'not a regex', - undefined, - 'Custom regex error', - ], - - // URL tests - ['URL', oz.url(), testUrl, undefined, undefined], - ['URL', oz.url(), 'not a url', undefined, 'Input is not a URL'], - [ - 'URL', - oz.url({ message: 'Custom URL error' }), - 'not a url', - undefined, - 'Custom URL error', - ], -] as const - -describe('custom Zod Types', () => { - it.each(cases)( - 'should validate %s', - (name, schema, value, mimeType, errorMessage) => { - expect(getCustomZodType(schema._def)).toEqual(name) - expect(getCustomZodFileMimeType(schema._def)).toEqual(mimeType) - - if (!errorMessage) { - expect(schema.safeParse(value)).toEqual({ - success: true, - data: value, - }) - } - else { - expect(schema.safeParse(value).error?.issues[0]?.message).toEqual( - errorMessage, - ) - } - }, - ) -}) diff --git a/packages/zod/tests/openapi.test-d.ts b/packages/zod/tests/openapi.test-d.ts deleted file mode 100644 index 580169bec..000000000 --- a/packages/zod/tests/openapi.test-d.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from 'zod' -import { oz } from '../src' - -describe('openapi function', () => { - const schema = z.object({ - name: z.string(), - }) - - it('infer schema for examples', () => { - oz.openapi(schema, { - examples: [{ name: '23' }], - }) - - oz.openapi(schema, { - // @ts-expect-error - invalid example - examples: [{ a: '23' }], - }) - - oz.openapi(schema, { - // @ts-expect-error - invalid example - examples: [12343], - }) - }) - - it('strict on input & output', () => { - const schema = z.object({ - name: z.string().transform(val => val.length), - }) - - oz.openapi(schema, { - // @ts-expect-error name should be never - examples: [{ name: '23' }], - }) - - oz.openapi( - schema, - { - examples: [{ name: '23' }], - }, - { mode: 'input' }, - ) - oz.openapi( - schema, - { - // @ts-expect-error invalid name - examples: [{ name: 23 }], - }, - { mode: 'input' }, - ) - - oz.openapi( - schema, - { - examples: [{ name: 23 }], - }, - { mode: 'output' }, - ) - oz.openapi( - schema, - { - // @ts-expect-error invalid name - examples: [{ name: '23' }], - }, - { mode: 'output' }, - ) - }) -}) diff --git a/packages/zod/tests/openapi.test.ts b/packages/zod/tests/openapi.test.ts deleted file mode 100644 index 4393a675a..000000000 --- a/packages/zod/tests/openapi.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { z, type ZodEffects } from 'zod' -import { getCustomJSONSchema, openapi } from '../src' - -describe('openapi function', () => { - const schema = z.object({ - id: z.string(), - }) - - const customSchema = { - type: 'object', - properties: { - example: { type: 'string' }, - }, - } as const - - it('should not return the same zod schema', () => { - const newSchema = openapi(schema, {}) - expectTypeOf(newSchema).toMatchTypeOf< - ZodEffects< - typeof schema, - { - id: string - }, - { - id: string - } - > - >() - - expect(newSchema._def.schema).toBe(schema) - expect(newSchema).not.toBe(schema) // prevent reference issue - }) - - it('should add a custom JSON schema to the Zod schema definition', () => { - const newSchema = openapi(schema, customSchema) - - expect(getCustomJSONSchema(newSchema._def)).toBe(customSchema) - expect(getCustomJSONSchema(newSchema._def, { mode: 'input' })).toBe( - customSchema, - ) - expect(getCustomJSONSchema(newSchema._def, { mode: 'output' })).toBe( - customSchema, - ) - }) - - it('should work on input mode', () => { - const newSchema = openapi(schema, customSchema, { mode: 'input' }) - - expect(getCustomJSONSchema(newSchema._def)).toBe(undefined) - expect(getCustomJSONSchema(newSchema._def, { mode: 'input' })).toBe( - customSchema, - ) - expect(getCustomJSONSchema(newSchema._def, { mode: 'output' })).toBe( - undefined, - ) - }) - - it('should work on output mode', () => { - const newSchema = openapi(schema, customSchema, { mode: 'output' }) - - expect(getCustomJSONSchema(newSchema._def)).toBe(undefined) - expect(getCustomJSONSchema(newSchema._def, { mode: 'input' })).toBe( - undefined, - ) - expect(getCustomJSONSchema(newSchema._def, { mode: 'output' })).toBe( - customSchema, - ) - }) -}) From 0823e1f4471771a4aaa8647f52e622ff51c7614e Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 20:52:06 +0700 Subject: [PATCH 10/11] lint fixed --- packages/zod/src/converter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index f5600c5ce..a124651bd 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -1,4 +1,4 @@ -import type { JSONSchema } from 'json-schema-typed' +import type { JSONSchema } from '@orpc/openapi' import type { ZodTypeAny } from 'zod' import { z } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' From d5a0dd71e024d2bc61b99d45d022a660832714af Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 6 Mar 2025 20:55:39 +0700 Subject: [PATCH 11/11] fix deps --- apps/content/package.json | 2 +- package.json | 2 +- packages/client/package.json | 2 +- packages/contract/package.json | 2 +- packages/openapi/package.json | 2 +- packages/react-query/package.json | 2 +- playgrounds/contract-openapi/package.json | 6 +- playgrounds/expressjs/package.json | 6 +- playgrounds/nextjs/package.json | 8 +- playgrounds/nuxt/package.json | 4 +- playgrounds/openapi/package.json | 6 +- pnpm-lock.yaml | 175 ++++------------------ 12 files changed, 47 insertions(+), 170 deletions(-) diff --git a/apps/content/package.json b/apps/content/package.json index 2f4c97477..2af10e8a3 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@shikijs/vitepress-twoslash": "^2.5.0", - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "vitepress": "1.6.3", "vitepress-plugin-group-icons": "^1.3.6", "vitepress-plugin-shiki-twoslash": "^0.0.6", diff --git a/package.json b/package.json index 2f32ec024..8bd85934f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@eslint-react/eslint-plugin": "^1.16.2", "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "@vitest/coverage-v8": "^3.0.4", "@vue/test-utils": "^2.4.6", "eslint": "^9.15.0", diff --git a/packages/client/package.json b/packages/client/package.json index 050fe5b35..e4395a94a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -51,6 +51,6 @@ "@orpc/standard-server-fetch": "workspace:*" }, "devDependencies": { - "zod": "^3.24.1" + "zod": "^3.24.2" } } diff --git a/packages/contract/package.json b/packages/contract/package.json index f9ad700c4..caaebbc39 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -42,6 +42,6 @@ "devDependencies": { "arktype": "2.0.0-rc.26", "valibot": "1.0.0-beta.9", - "zod": "^3.24.1" + "zod": "^3.24.2" } } diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 53892820d..0330abb47 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -77,6 +77,6 @@ "rou3": "^0.5.1" }, "devDependencies": { - "zod": "^3.24.1" + "zod": "^3.24.2" } } diff --git a/packages/react-query/package.json b/packages/react-query/package.json index 0af040fd7..a5ed9509a 100644 --- a/packages/react-query/package.json +++ b/packages/react-query/package.json @@ -45,6 +45,6 @@ "@orpc/shared": "workspace:*" }, "devDependencies": { - "zod": "^3.24.1" + "zod": "^3.24.2" } } diff --git a/playgrounds/contract-openapi/package.json b/playgrounds/contract-openapi/package.json index 5bd76437a..3b7a559ff 100644 --- a/playgrounds/contract-openapi/package.json +++ b/playgrounds/contract-openapi/package.json @@ -15,11 +15,11 @@ "@orpc/react-query": "next", "@orpc/server": "next", "@orpc/zod": "next", - "@tanstack/react-query": "^5.63.0", - "zod": "^3.24.1" + "@tanstack/react-query": "^5.66.4", + "zod": "^3.24.2" }, "devDependencies": { - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "tsx": "^4.19.2", "typescript": "5.7.3" } diff --git a/playgrounds/expressjs/package.json b/playgrounds/expressjs/package.json index c2621a1a4..ed23ba103 100644 --- a/playgrounds/expressjs/package.json +++ b/playgrounds/expressjs/package.json @@ -14,13 +14,13 @@ "@orpc/react-query": "next", "@orpc/server": "next", "@orpc/zod": "next", - "@tanstack/react-query": "^5.63.0", + "@tanstack/react-query": "^5.66.4", "express": "^4.21.1", - "zod": "^3.24.1" + "zod": "^3.24.2" }, "devDependencies": { "@types/express": "^5.0.0", - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "tsx": "^4.19.2", "typescript": "5.7.3" } diff --git a/playgrounds/nextjs/package.json b/playgrounds/nextjs/package.json index fb9b43cc5..0c1a8585b 100644 --- a/playgrounds/nextjs/package.json +++ b/playgrounds/nextjs/package.json @@ -15,14 +15,14 @@ "@orpc/react-query": "next", "@orpc/server": "next", "@orpc/zod": "next", - "@tanstack/react-query": "^5.63.0", - "next": "15.1.4", + "@tanstack/react-query": "^5.66.4", + "next": "^15.1.7", "react": "19.0.0", "react-dom": "19.0.0", - "zod": "^3.24.1" + "zod": "^3.24.2" }, "devDependencies": { - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "typescript": "5.7.3" diff --git a/playgrounds/nuxt/package.json b/playgrounds/nuxt/package.json index f4140fea2..8717f722b 100644 --- a/playgrounds/nuxt/package.json +++ b/playgrounds/nuxt/package.json @@ -15,10 +15,10 @@ "@orpc/server": "next", "@orpc/vue-query": "next", "@orpc/zod": "next", - "@tanstack/vue-query": "^5.63.0", + "@tanstack/vue-query": "^5.66.4", "nuxt": "^3.14.1592", "vue": "latest", "vue-router": "latest", - "zod": "^3.24.1" + "zod": "^3.24.2" } } diff --git a/playgrounds/openapi/package.json b/playgrounds/openapi/package.json index fcb8f5962..d575385fc 100644 --- a/playgrounds/openapi/package.json +++ b/playgrounds/openapi/package.json @@ -14,11 +14,11 @@ "@orpc/react-query": "next", "@orpc/server": "next", "@orpc/zod": "next", - "@tanstack/react-query": "^5.63.0", - "zod": "^3.24.1" + "@tanstack/react-query": "^5.66.4", + "zod": "^3.24.2" }, "devDependencies": { - "@types/node": "^22.10.0", + "@types/node": "^22.13.1", "tsx": "^4.19.2", "typescript": "5.7.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 374b43733..8038528fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ importers: specifier: ^16.0.1 version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 '@vitest/coverage-v8': specifier: ^3.0.4 @@ -109,7 +109,7 @@ importers: specifier: ^2.5.0 version: 2.5.0(@nuxt/kit@3.15.4(magicast@0.3.5))(typescript@5.7.3) '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 vitepress: specifier: 1.6.3 @@ -137,7 +137,7 @@ importers: version: link:../standard-server-fetch devDependencies: zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 packages/contract: @@ -162,7 +162,7 @@ importers: specifier: 1.0.0-beta.9 version: 1.0.0-beta.9(typescript@5.7.3) zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 packages/openapi: @@ -202,7 +202,7 @@ importers: version: 0.5.1 devDependencies: zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 packages/openapi-client: @@ -233,7 +233,7 @@ importers: version: 19.0.0 devDependencies: zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 packages/server: @@ -400,14 +400,14 @@ importers: specifier: next version: link:../../packages/zod '@tanstack/react-query': - specifier: ^5.63.0 - version: 5.66.5(react@19.0.0) + specifier: ^5.66.4 + version: 5.66.7(react@19.0.0) zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 devDependencies: '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 tsx: specifier: ^4.19.2 @@ -434,20 +434,20 @@ importers: specifier: next version: link:../../packages/zod '@tanstack/react-query': - specifier: ^5.63.0 - version: 5.66.5(react@19.0.0) + specifier: ^5.66.4 + version: 5.66.7(react@19.0.0) express: specifier: ^4.21.1 version: 4.21.2 zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 devDependencies: '@types/express': specifier: ^5.0.0 version: 5.0.0 '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 tsx: specifier: ^4.19.2 @@ -474,11 +474,11 @@ importers: specifier: next version: link:../../packages/zod '@tanstack/react-query': - specifier: ^5.63.0 - version: 5.66.5(react@19.0.0) + specifier: ^5.66.4 + version: 5.66.7(react@19.0.0) next: - specifier: 15.1.4 - version: 15.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: ^15.1.7 + version: 15.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -486,11 +486,11 @@ importers: specifier: 19.0.0 version: 19.0.0(react@19.0.0) zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 devDependencies: '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 '@types/react': specifier: ^18.3.12 @@ -520,7 +520,7 @@ importers: specifier: next version: link:../../packages/zod '@tanstack/vue-query': - specifier: ^5.63.0 + specifier: ^5.66.4 version: 5.66.4(vue@3.5.13(typescript@5.7.3)) nuxt: specifier: ^3.14.1592 @@ -532,7 +532,7 @@ importers: specifier: latest version: 4.5.0(vue@3.5.13(typescript@5.7.3)) zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 playgrounds/openapi: @@ -553,14 +553,14 @@ importers: specifier: next version: link:../../packages/zod '@tanstack/react-query': - specifier: ^5.63.0 - version: 5.66.5(react@19.0.0) + specifier: ^5.66.4 + version: 5.66.7(react@19.0.0) zod: - specifier: ^3.24.1 + specifier: ^3.24.2 version: 3.24.2 devDependencies: '@types/node': - specifier: ^22.10.0 + specifier: ^22.13.1 version: 22.13.4 tsx: specifier: ^4.19.2 @@ -1843,102 +1843,51 @@ packages: resolution: {integrity: sha512-q3L9i3HoNfz0SGpTIS4zTcKBbRkxzCRpd169eyiTuk3IwcPC3/85mzLHranlKo2b+HYT0gu37YxGB45aD8A3Tw==} engines: {node: '>=18.0.0'} - '@next/env@15.1.4': - resolution: {integrity: sha512-2fZ5YZjedi5AGaeoaC0B20zGntEHRhi2SdWcu61i48BllODcAmmtj8n7YarSPt4DaTsJaBFdxQAVEVzgmx2Zpw==} - '@next/env@15.1.7': resolution: {integrity: sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==} - '@next/swc-darwin-arm64@15.1.4': - resolution: {integrity: sha512-wBEMBs+np+R5ozN1F8Y8d/Dycns2COhRnkxRc+rvnbXke5uZBHkUGFgWxfTXn5rx7OLijuUhyfB+gC/ap58dDw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@15.1.7': resolution: {integrity: sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.1.4': - resolution: {integrity: sha512-7sgf5rM7Z81V9w48F02Zz6DgEJulavC0jadab4ZsJ+K2sxMNK0/BtF8J8J3CxnsJN3DGcIdC260wEKssKTukUw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@15.1.7': resolution: {integrity: sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.1.4': - resolution: {integrity: sha512-JaZlIMNaJenfd55kjaLWMfok+vWBlcRxqnRoZrhFQrhM1uAehP3R0+Aoe+bZOogqlZvAz53nY/k3ZyuKDtT2zQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-gnu@15.1.7': resolution: {integrity: sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.1.4': - resolution: {integrity: sha512-7EBBjNoyTO2ipMDgCiORpwwOf5tIueFntKjcN3NK+GAQD7OzFJe84p7a2eQUeWdpzZvhVXuAtIen8QcH71ZCOQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-musl@15.1.7': resolution: {integrity: sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.1.4': - resolution: {integrity: sha512-9TGEgOycqZFuADyFqwmK/9g6S0FYZ3tphR4ebcmCwhL8Y12FW8pIBKJvSwV+UBjMkokstGNH+9F8F031JZKpHw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-gnu@15.1.7': resolution: {integrity: sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.1.4': - resolution: {integrity: sha512-0578bLRVDJOh+LdIoKvgNDz77+Bd85c5JrFgnlbI1SM3WmEQvsjxTA8ATu9Z9FCiIS/AliVAW2DV/BDwpXbtiQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-musl@15.1.7': resolution: {integrity: sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.1.4': - resolution: {integrity: sha512-JgFCiV4libQavwII+kncMCl30st0JVxpPOtzWcAI2jtum4HjYaclobKhj+JsRu5tFqMtA5CJIa0MvYyuu9xjjQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@15.1.7': resolution: {integrity: sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.1.4': - resolution: {integrity: sha512-xxsJy9wzq7FR5SqPCUqdgSXiNXrMuidgckBa8nH9HtjjxsilgcN6VgXF6tZ3uEWuVEadotQJI8/9EQ6guTC4Yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@15.1.7': resolution: {integrity: sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==} engines: {node: '>= 10'} @@ -5154,27 +5103,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - next@15.1.4: - resolution: {integrity: sha512-mTaq9dwaSuwwOrcu3ebjDYObekkxRnXpuVL21zotM8qE2W0HBOdVIdg2Li9QjMEZrj73LN96LcWcz62V19FjAg==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - next@15.1.7: resolution: {integrity: sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -8210,55 +8138,29 @@ snapshots: '@netlify/node-cookies': 0.1.0 urlpattern-polyfill: 8.0.2 - '@next/env@15.1.4': {} - '@next/env@15.1.7': {} - '@next/swc-darwin-arm64@15.1.4': - optional: true - '@next/swc-darwin-arm64@15.1.7': optional: true - '@next/swc-darwin-x64@15.1.4': - optional: true - '@next/swc-darwin-x64@15.1.7': optional: true - '@next/swc-linux-arm64-gnu@15.1.4': - optional: true - '@next/swc-linux-arm64-gnu@15.1.7': optional: true - '@next/swc-linux-arm64-musl@15.1.4': - optional: true - '@next/swc-linux-arm64-musl@15.1.7': optional: true - '@next/swc-linux-x64-gnu@15.1.4': - optional: true - '@next/swc-linux-x64-gnu@15.1.7': optional: true - '@next/swc-linux-x64-musl@15.1.4': - optional: true - '@next/swc-linux-x64-musl@15.1.7': optional: true - '@next/swc-win32-arm64-msvc@15.1.4': - optional: true - '@next/swc-win32-arm64-msvc@15.1.7': optional: true - '@next/swc-win32-x64-msvc@15.1.4': - optional: true - '@next/swc-win32-x64-msvc@15.1.7': optional: true @@ -12286,31 +12188,6 @@ snapshots: negotiator@0.6.3: {} - next@15.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - '@next/env': 15.1.4 - '@swc/counter': 0.1.3 - '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001698 - postcss: 8.4.31 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(react@19.0.0) - optionalDependencies: - '@next/swc-darwin-arm64': 15.1.4 - '@next/swc-darwin-x64': 15.1.4 - '@next/swc-linux-arm64-gnu': 15.1.4 - '@next/swc-linux-arm64-musl': 15.1.4 - '@next/swc-linux-x64-gnu': 15.1.4 - '@next/swc-linux-x64-musl': 15.1.4 - '@next/swc-win32-arm64-msvc': 15.1.4 - '@next/swc-win32-x64-msvc': 15.1.4 - sharp: 0.33.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - next@15.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.1.7