diff --git a/packages/openapi/src/fetch/index.ts b/packages/openapi/src/fetch/index.ts index 6298504c..1a7ae107 100644 --- a/packages/openapi/src/fetch/index.ts +++ b/packages/openapi/src/fetch/index.ts @@ -6,3 +6,4 @@ export * from './openapi-handler-server' export * from './openapi-handler-serverless' export * from './openapi-payload-codec' export * from './openapi-procedure-matcher' +export * from './schema-coercer' diff --git a/packages/openapi/src/fetch/openapi-handler.test.ts b/packages/openapi/src/fetch/openapi-handler.test.ts index d07601a0..a49a56be 100644 --- a/packages/openapi/src/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/fetch/openapi-handler.test.ts @@ -187,4 +187,29 @@ describe.each(hono)('openAPIHandler: %s', (_, hono) => { headers: new Headers({ [ORPC_HANDLER_HEADER]: ORPC_HANDLER_VALUE }), }))).toBe(false) }) + + it('schema coercer', async () => { + const coerce = vi.fn().mockReturnValue('__mocked__') + + const handler = new OpenAPIHandler(hono, router, { + schemaCoercers: [ + { + coerce, + }, + ], + }) + + const mockRequest = new Request('https://example.com/ping?value=123', { + headers: new Headers({}), + }) + + const response = await handler.fetch(mockRequest) + + expect(response?.status).toBe(200) + + expect(coerce).toBeCalledTimes(1) + expect(coerce).toBeCalledWith(undefined, { value: '123' }) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledTimes(1) + expect(vi.mocked(createProcedureClient).mock.results[0]?.value).toBeCalledWith('__mocked__', { signal: undefined }) + }) }) diff --git a/packages/openapi/src/fetch/openapi-handler.ts b/packages/openapi/src/fetch/openapi-handler.ts index 8b18a9b6..55d49584 100644 --- a/packages/openapi/src/fetch/openapi-handler.ts +++ b/packages/openapi/src/fetch/openapi-handler.ts @@ -6,6 +6,7 @@ import { InputBuilderFull, type PublicInputBuilderFull } from './input-builder-f import { InputBuilderSimple } from './input-builder-simple' import { OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec } from './openapi-payload-codec' import { type Hono, OpenAPIProcedureMatcher, type PublicOpenAPIProcedureMatcher } from './openapi-procedure-matcher' +import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' export type OpenAPIHandlerOptions = & Hooks @@ -14,6 +15,7 @@ export type OpenAPIHandlerOptions = payloadCodec?: PublicOpenAPIPayloadCodec inputBuilderSimple?: PublicInputBuilderSimple inputBuilderFull?: PublicInputBuilderFull + schemaCoercers?: SchemaCoercer[] } export class OpenAPIHandler implements ConditionalFetchHandler { @@ -21,6 +23,7 @@ export class OpenAPIHandler implements ConditionalFetchHandle private readonly payloadCodec: PublicOpenAPIPayloadCodec private readonly inputBuilderSimple: PublicInputBuilderSimple private readonly inputBuilderFull: PublicInputBuilderFull + private readonly compositeSchemaCoercer: SchemaCoercer constructor( hono: Hono, @@ -31,6 +34,7 @@ export class OpenAPIHandler implements ConditionalFetchHandle this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec() this.inputBuilderSimple = options?.inputBuilderSimple ?? new InputBuilderSimple() this.inputBuilderFull = options?.inputBuilderFull ?? new InputBuilderFull() + this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? []) } condition(request: Request): boolean { @@ -67,13 +71,15 @@ export class OpenAPIHandler implements ConditionalFetchHandle const input = this.inputBuilderSimple.build(match.params, decodedPayload) + const coercedInput = this.compositeSchemaCoercer.coerce(match.procedure['~orpc'].contract['~orpc'].InputSchema, input) + const client = createProcedureClient({ context, procedure: match.procedure, path: match.path, }) - const output = await client(input, { signal: options?.signal }) + const output = await client(coercedInput, { signal: options?.signal }) const { body, headers } = this.payloadCodec.encode(output) diff --git a/packages/openapi/src/fetch/schema-coercer.test.ts b/packages/openapi/src/fetch/schema-coercer.test.ts new file mode 100644 index 00000000..f3d6eae1 --- /dev/null +++ b/packages/openapi/src/fetch/schema-coercer.test.ts @@ -0,0 +1,169 @@ +import type { Schema } from '@orpc/contract' +import { z } from 'zod' +import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' + +// Mock implementation of SchemaCoercer for testing +class MockSchemaCoercer implements SchemaCoercer { + constructor(private readonly transform: (value: unknown) => unknown) { } + + coerce(schema: Schema, value: unknown): unknown { + return this.transform(value) + } +} + +describe('compositeSchemaCoercer', () => { + describe('coerce', () => { + it('should apply coercers in sequence with number schema', () => { + const addOneCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value + 1 : value)) + const multiplyByTwoCoercer = new MockSchemaCoercer(value => (typeof value === 'number' ? value * 2 : value)) + + const composite = new CompositeSchemaCoercer([addOneCoercer, multiplyByTwoCoercer]) + const schema = z.number() + + const result = composite.coerce(schema, 5) + + // First coercer adds 1 (5 -> 6), then second coercer multiplies by 2 (6 -> 12) + expect(result).toBe(12) + }) + + it('should handle string to number coercion', () => { + const stringToNumberCoercer = new MockSchemaCoercer(value => + typeof value === 'string' ? Number.parseInt(value, 10) : value, + ) + + const composite = new CompositeSchemaCoercer([stringToNumberCoercer]) + const schema = z.number() + + const result = composite.coerce(schema, '123') + + expect(result).toBe(123) + expect(typeof result).toBe('number') + }) + + it('should handle empty coercer array', () => { + const composite = new CompositeSchemaCoercer([]) + const schema = z.string() + const value = 'test' + + const result = composite.coerce(schema, value) + + expect(result).toBe(value) + }) + + it('should pass schema to each coercer', () => { + const schema = z.string().regex(/^test/) + const mockCoercer = { + coerce: vi.fn().mockImplementation((_, value) => value), + } + + const composite = new CompositeSchemaCoercer([mockCoercer]) + composite.coerce(schema, 'test') + + expect(mockCoercer.coerce).toHaveBeenCalledWith(schema, 'test') + }) + + it('should handle complex object schemas', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + isActive: z.boolean(), + }) + + const objectCoercer = new MockSchemaCoercer((value: any) => { + if (typeof value !== 'object' || value === null) + return value + return { + ...value, + age: typeof value.age === 'string' ? Number.parseInt(value.age, 10) : value.age, + } + }) + + const composite = new CompositeSchemaCoercer([objectCoercer]) + + const result = composite.coerce(schema, { + name: 'John', + age: '30', + isActive: true, + }) + + expect(result).toEqual({ + name: 'John', + age: 30, + isActive: true, + }) + }) + + it('should handle array schemas', () => { + const schema = z.array(z.number()) + const arrayCoercer = new MockSchemaCoercer((value) => { + if (!Array.isArray(value)) + return value + return value.map(item => typeof item === 'string' ? Number.parseInt(item, 10) : item) + }) + + const composite = new CompositeSchemaCoercer([arrayCoercer]) + + const result = composite.coerce(schema, ['1', '2', '3']) + + expect(result).toEqual([1, 2, 3]) + }) + + it('should maintain coercer order with complex transformations', () => { + const transforms: unknown[] = [] + const schema = z.any() + + const firstCoercer = new MockSchemaCoercer((value) => { + transforms.push(1) + return value + }) + + const secondCoercer = new MockSchemaCoercer((value) => { + transforms.push(2) + return value + }) + + const thirdCoercer = new MockSchemaCoercer((value) => { + transforms.push(3) + return value + }) + + const composite = new CompositeSchemaCoercer([firstCoercer, secondCoercer, thirdCoercer]) + + composite.coerce(schema, 'test') + + expect(transforms).toEqual([1, 2, 3]) + }) + + it('should handle optional fields in object schemas', () => { + const schema = z.object({ + required: z.string(), + optional: z.number().optional(), + }) + + const objectCoercer = new MockSchemaCoercer((value: any) => { + if (typeof value !== 'object' || value === null) + return value + return { + ...value, + optional: value.optional !== undefined + ? typeof value.optional === 'string' + ? Number.parseInt(value.optional, 10) + : value.optional + : undefined, + } + }) + + const composite = new CompositeSchemaCoercer([objectCoercer]) + + const result = composite.coerce(schema, { + required: 'test', + optional: '42', + }) + + expect(result).toEqual({ + required: 'test', + optional: 42, + }) + }) + }) +}) diff --git a/packages/openapi/src/fetch/schema-coercer.ts b/packages/openapi/src/fetch/schema-coercer.ts new file mode 100644 index 00000000..0ccb4237 --- /dev/null +++ b/packages/openapi/src/fetch/schema-coercer.ts @@ -0,0 +1,20 @@ +import type { Schema } from '@orpc/contract' + +export interface SchemaCoercer { + coerce: (schema: Schema, value: unknown) => unknown +} + +export class CompositeSchemaCoercer implements SchemaCoercer { + constructor( + private readonly coercers: SchemaCoercer[], + ) {} + + coerce(schema: Schema, value: unknown): unknown { + let current = value + for (const coercer of this.coercers) { + current = coercer.coerce(schema, current) + } + + return current + } +} diff --git a/packages/zod/package.json b/packages/zod/package.json index 0fad2b49..fe8f287b 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -41,6 +41,9 @@ "build:watch": "pnpm run build --watch", "type:check": "tsc -b" }, + "peerDependencies": { + "@orpc/openapi": "workspace:*" + }, "dependencies": { "json-schema-typed": "^8.0.1", "wildcard-match": "^5.1.3", diff --git a/packages/zod/src/coercer.ts b/packages/zod/src/coercer.ts new file mode 100644 index 00000000..cfdd7130 --- /dev/null +++ b/packages/zod/src/coercer.ts @@ -0,0 +1,544 @@ +import type { Schema } from '@orpc/contract' +import type { SchemaCoercer } from '@orpc/openapi/fetch' +import { guard } from '@orpc/shared' +import { getCustomZodType } from '@orpc/zod' +import { isPlainObject } from 'is-what' +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, +} from 'zod' + +export class ZodCoercer implements SchemaCoercer { + coerce(schema: Schema, value: unknown): unknown { + if (schema && schema['~standard'].vendor !== 'zod') { + return value + } + + const zodSchema = schema as ZodTypeAny + const coerced = zodCoerceInternal(zodSchema, value) + return coerced + } +} + +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 customType = getCustomZodType(schema._def) + + if (customType === 'Invalid Date') { + if ( + typeof value === 'string' + && value.toLocaleLowerCase() === 'invalid date' + ) { + return new Date('Invalid Date') + } + } + 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 + } + } + } + + if (schema._def.typeName === undefined) { + return value + } + + const typeName = schema._def.typeName as ZodFirstPartyTypeKind + + if (typeName === ZodFirstPartyTypeKind.ZodNumber) { + if (options_?.bracketNotation && typeof value === 'string') { + const num = Number(value) + if (!Number.isNaN(num)) { + return num + } + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodNaN) { + if (typeof value === 'string' && value.toLocaleLowerCase() === 'nan') { + return Number.NaN + } + } + + // + 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 + } + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodNull) { + if ( + options_?.bracketNotation + && typeof value === 'string' + && value.toLowerCase() === 'null' + ) { + return null + } + } + + // + else if ( + typeName === ZodFirstPartyTypeKind.ZodUndefined + || typeName === ZodFirstPartyTypeKind.ZodVoid + ) { + if (typeof value === 'string' && value.toLowerCase() === 'undefined') { + return undefined + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodDate) { + if ( + typeof value === 'string' + && (value.includes('-') + || value.includes(':') + || value.toLocaleLowerCase() === 'invalid date') + ) { + return new Date(value) + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodBigInt) { + if (typeof value === 'string') { + const num = guard(() => BigInt(value)) + if (num !== undefined) { + return num + } + } + } + + // + 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_)) + } + + if (options_?.bracketNotation) { + if (value === undefined) { + return [] + } + + if ( + isPlainObject(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) + + const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) + + for (const i of indexes) { + arr[i] = zodCoerceInternal(schema_._def.type, value[i], options_) + } + + return arr + } + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodObject) { + const schema_ = schema as ZodObject<{ [k: string]: ZodTypeAny }> + + if (isPlainObject(value)) { + const newObj: Record = {} + + const keys = new Set([ + ...Object.keys(value), + ...Object.keys(schema_.shape), + ]) + + for (const k of keys) { + if (!(k in value)) + continue + + const v = value[k] + newObj[k] = zodCoerceInternal( + schema_.shape[k] ?? schema_._def.catchall, + v, + options_, + ) + } + + return newObj + } + + if (options_?.bracketNotation) { + if (value === undefined) { + return {} + } + + if (Array.isArray(value) && value.length === 1) { + const emptySchema = schema_.shape[''] ?? schema_._def.catchall + return { '': zodCoerceInternal(emptySchema, value[0], options_) } + } + } + } + + // + 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_)), + ) + } + + if (options_?.bracketNotation) { + if (value === undefined) { + return new Set() + } + + if ( + isPlainObject(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) + + const arr = Array.from({ length: (indexes.at(-1) ?? -1) + 1 }) + + for (const i of indexes) { + arr[i] = zodCoerceInternal(schema_._def.valueType, value[i], options_) + } + + return new Set(arr) + } + } + } + + // + 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_), + ]), + ) + } + + if (options_?.bracketNotation) { + if (value === undefined) { + return new Map() + } + + if (isPlainObject(value)) { + const arr = Array.from({ length: Object.keys(value).length }) + .fill(undefined) + .map((_, i) => + isPlainObject(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, + ) + + if (arr.every(v => !!v)) { + return new Map( + arr.map(([k, v]) => [ + zodCoerceInternal(schema_._def.keyType, k, options_), + zodCoerceInternal(schema_._def.valueType, v, options_), + ]), + ) + } + } + } + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodRecord) { + const schema_ = schema as ZodRecord + + if (isPlainObject(value)) { + const newObj: any = {} + + 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 + } + + return newObj + } + } + + // + else if ( + typeName === ZodFirstPartyTypeKind.ZodUnion + || typeName === ZodFirstPartyTypeKind.ZodDiscriminatedUnion + ) { + const schema_ = schema as + | ZodUnion<[ZodTypeAny]> + | ZodDiscriminatedUnion]> + + if (schema_.safeParse(value).success) { + return value + } + + const results: [unknown, number][] = [] + for (const s of schema_._def.options) { + const newValue = zodCoerceInternal(s, value, { ...options_, isRoot }) + + if (newValue === value) + continue + + const result = schema_.safeParse(newValue) + + if (result.success) { + return newValue + } + + results.push([newValue, result.error.issues.length]) + } + + if (results.length === 0) { + return value + } + + return results.sort((a, b) => a[1] - b[1])[0]?.[0] + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodIntersection) { + const schema_ = schema as ZodIntersection + + return zodCoerceInternal( + schema_._def.right, + zodCoerceInternal(schema_._def.left, value, { ...options_, isRoot }), + { ...options_, isRoot }, + ) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodReadonly) { + const schema_ = schema as ZodReadonly + + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodPipeline) { + const schema_ = schema as ZodPipeline + + return zodCoerceInternal(schema_._def.in, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodLazy) { + const schema_ = schema as ZodLazy + + return zodCoerceInternal(schema_._def.getter(), value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodEffects) { + const schema_ = schema as ZodEffects + + return zodCoerceInternal(schema_._def.schema, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodBranded) { + const schema_ = schema as ZodBranded + + return zodCoerceInternal(schema_._def.type, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodCatch) { + const schema_ = schema as ZodCatch + + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodDefault) { + const schema_ = schema as ZodDefault + + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodNullable) { + const schema_ = schema as ZodNullable + + if (value === null) { + return null + } + + if (typeof value === 'string' && value.toLowerCase() === 'null') { + return schema_.safeParse(value).success ? value : null + } + + return zodCoerceInternal(schema_._def.innerType, value, { ...options_, isRoot }) + } + + // + 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 }) + } + + // + else if (typeName === ZodFirstPartyTypeKind.ZodNativeEnum) { + const schema_ = schema as ZodNativeEnum + + if (Object.values(schema_._def.values).includes(value as any)) { + return value + } + + if (options?.bracketNotation && typeof value === 'string') { + for (const expectedValue of Object.values(schema_._def.values)) { + if (expectedValue.toString() === value) { + return expectedValue + } + } + } + } + + // + 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() + + if (lower === 'false' || lower === 'off' || lower === 'f') { + return false + } + + if (lower === 'true' || lower === 'on' || lower === 't') { + return true + } + } + else if (expectedValue === null) { + if (value.toLocaleLowerCase() === 'null') { + return null + } + } + } + } + } + + // + else { + const _expected: + | ZodFirstPartyTypeKind.ZodString + | ZodFirstPartyTypeKind.ZodEnum + | ZodFirstPartyTypeKind.ZodSymbol + | ZodFirstPartyTypeKind.ZodPromise + | ZodFirstPartyTypeKind.ZodFunction + | ZodFirstPartyTypeKind.ZodAny + | ZodFirstPartyTypeKind.ZodUnknown + | ZodFirstPartyTypeKind.ZodNever = typeName + } + + return value +} diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 8cfbe053..b0f75c87 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,243 +1,2 @@ -/// - -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, -} +export * from './coercer' +export * from './schemas' diff --git a/packages/zod/src/schemas.ts b/packages/zod/src/schemas.ts new file mode 100644 index 00000000..d59f49d7 --- /dev/null +++ b/packages/zod/src/schemas.ts @@ -0,0 +1,243 @@ +/// + +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/tsconfig.json b/packages/zod/tsconfig.json index b64920aa..a1c619db 100644 --- a/packages/zod/tsconfig.json +++ b/packages/zod/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "types": [] }, - "references": [], + "references": [ + { "path": "../openapi" } + ], "include": ["src"], "exclude": [ "**/*.test.*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5350da06..973b88a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: packages/zod: dependencies: + '@orpc/openapi': + specifier: workspace:* + version: link:../openapi json-schema-typed: specifier: ^8.0.1 version: 8.0.1