diff --git a/apps/content/content/docs/index.mdx b/apps/content/content/docs/index.mdx index af24e22f..b8fc3a85 100644 --- a/apps/content/content/docs/index.mdx +++ b/apps/content/content/docs/index.mdx @@ -152,11 +152,11 @@ import { RPCHandler } from '@orpc/server/node' import { OpenAPIHandler } from '@orpc/openapi/node' import { createServer } from 'node:http' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) diff --git a/apps/content/content/docs/server/integrations.mdx b/apps/content/content/docs/server/integrations.mdx index 2aac4134..0495b0d2 100644 --- a/apps/content/content/docs/server/integrations.mdx +++ b/apps/content/content/docs/server/integrations.mdx @@ -16,11 +16,11 @@ Whether you're targeting serverless, edge environments, or traditional backends, import { RPCHandler } from '@orpc/server/fetch' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) @@ -60,11 +60,11 @@ import { createServer } from 'node:http' import { RPCHandler } from '@orpc/server/node' import { OpenAPIHandler } from '@orpc/openapi/node' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) const rpcHandler = new RPCHandler(router) @@ -108,13 +108,13 @@ import express from 'express' import { RPCHandler } from '@orpc/server/node' import { OpenAPIHandler } from '@orpc/openapi/node' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const app = express() const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) @@ -158,14 +158,14 @@ import { Hono } from 'hono' import { RPCHandler, createMiddleware } from '@orpc/server/hono' import { OpenAPIHandler } from '@orpc/openapi/hono' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const app = new Hono() const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) @@ -194,11 +194,11 @@ export default app import { OpenAPIHandler } from '@orpc/openapi/next' import { serve } from '@orpc/server/next' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) @@ -230,11 +230,11 @@ export const { GET, POST, PUT, PATCH, DELETE } = serve(rpcHandler, { import { RPCHandler } from '@orpc/server/fetch' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from 'examples/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) diff --git a/apps/content/examples/contract.ts b/apps/content/examples/contract.ts index 1f446b1f..fdf2a7fa 100644 --- a/apps/content/examples/contract.ts +++ b/apps/content/examples/contract.ts @@ -1,7 +1,7 @@ import type { InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract' import { oc } from '@orpc/contract' import { implement, ORPCError } from '@orpc/server' -import { oz, ZodCoercer } from '@orpc/zod' +import { oz, ZodAutoCoercePlugin } from '@orpc/zod' import { z } from 'zod' // Define your contract first @@ -124,8 +124,8 @@ import { createServer } from 'node:http' import { OpenAPIHandler } from '@orpc/openapi/node' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) diff --git a/apps/content/examples/server.ts b/apps/content/examples/server.ts index fb7e77c0..f1d95c9a 100644 --- a/apps/content/examples/server.ts +++ b/apps/content/examples/server.ts @@ -1,6 +1,6 @@ import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server' import { ORPCError, os } from '@orpc/server' -import { oz, ZodCoercer } from '@orpc/zod' +import { oz, ZodAutoCoercePlugin } from '@orpc/zod' import { z } from 'zod' export type Context = { user?: { id: string } } @@ -95,8 +95,8 @@ import { createServer } from 'node:http' import { OpenAPIHandler } from '@orpc/openapi/node' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) diff --git a/packages/openapi/src/adapters/standard/index.ts b/packages/openapi/src/adapters/standard/index.ts index 67d5f4e5..e206bf31 100644 --- a/packages/openapi/src/adapters/standard/index.ts +++ b/packages/openapi/src/adapters/standard/index.ts @@ -3,4 +3,3 @@ export * from './openapi-codec' export * from './openapi-handler' export * from './openapi-matcher' export * from './openapi-serializer' -export * from './schema-coercer' diff --git a/packages/openapi/src/adapters/standard/openapi-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-codec.test.ts index f2a32085..e2062fb3 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.test.ts @@ -13,10 +13,7 @@ describe('openAPICodec', () => { deserialize: vi.fn(), } as any - const coerce = vi.fn((schema, value) => value) - const codec = new OpenAPICodec({ - schemaCoercers: [{ coerce }], serializer, }) @@ -40,8 +37,6 @@ describe('openAPICodec', () => { expect(serializer.deserialize).toHaveBeenCalledOnce() expect(serializer.deserialize).toHaveBeenCalledWith(url.searchParams) - expect(coerce).toHaveBeenCalledOnce() - expect(coerce).toHaveBeenCalledWith(ping['~orpc'].inputSchema, '__deserialized__') }) it('with non-GET method', async () => { @@ -61,8 +56,6 @@ describe('openAPICodec', () => { expect(serializer.deserialize).toHaveBeenCalledOnce() expect(serializer.deserialize).toHaveBeenCalledWith(serialized) - expect(coerce).toHaveBeenCalledOnce() - expect(coerce).toHaveBeenCalledWith(ping['~orpc'].inputSchema, '__deserialized__') }) }) diff --git a/packages/openapi/src/adapters/standard/openapi-codec.ts b/packages/openapi/src/adapters/standard/openapi-codec.ts index 774b41eb..222a692d 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.ts @@ -1,23 +1,18 @@ import type { AnyProcedure } from '@orpc/server' import type { StandardBody, StandardCodec, StandardHeaders, StandardParams, StandardRequest, StandardResponse } from '@orpc/server/standard' import { fallbackContractConfig, type ORPCError } from '@orpc/contract' -import { isPlainObject, once } from '@orpc/shared' +import { isPlainObject } from '@orpc/shared' import { OpenAPISerializer } from './openapi-serializer' -import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' export interface OpenAPICodecOptions { serializer?: OpenAPISerializer - schemaCoercers?: SchemaCoercer[] } export class OpenAPICodec implements StandardCodec { private readonly serializer: OpenAPISerializer - private readonly compositeSchemaCoercer: SchemaCoercer constructor(options?: OpenAPICodecOptions) { this.serializer = options?.serializer ?? new OpenAPISerializer() - - this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? []) } async decode(request: StandardRequest, params: StandardParams | undefined, procedure: AnyProcedure): Promise { @@ -29,31 +24,36 @@ export class OpenAPICodec implements StandardCodec { : this.serializer.deserialize(await request.body()) if (data === undefined) { - return this.compositeSchemaCoercer.coerce(procedure['~orpc'].inputSchema, params) + return params } if (isPlainObject(data)) { - return this.compositeSchemaCoercer.coerce(procedure['~orpc'].inputSchema, { + return { ...params, ...data, - }) + } } - return this.compositeSchemaCoercer.coerce(procedure['~orpc'].inputSchema, data) + return data } - const query = once(() => { + const deserializeSearchParams = () => { return this.serializer.deserialize(request.url.searchParams) - }) + } - return this.compositeSchemaCoercer.coerce(procedure['~orpc'].inputSchema, { + return { params, get query() { - return query() + const value = deserializeSearchParams() + Object.defineProperty(this, 'query', { value, writable: true }) + return value + }, + set query(value) { + Object.defineProperty(this, 'query', { value, writable: true }) }, headers: request.headers, body: this.serializer.deserialize(await request.body()), - }) + } } encode(output: unknown, procedure: AnyProcedure): StandardResponse { diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 35b42c45..ffad7130 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -114,7 +114,7 @@ export class OpenAPIMatcher implements StandardMatcher { return { path: match.data.path, procedure: match.data.procedure, - params: match.params, + params: match.params ? { ...match.params } : undefined, // normalize params to be a plain object } } } diff --git a/packages/openapi/src/adapters/standard/schema-coercer.test.ts b/packages/openapi/src/adapters/standard/schema-coercer.test.ts deleted file mode 100644 index f3d6eae1..00000000 --- a/packages/openapi/src/adapters/standard/schema-coercer.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -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/adapters/standard/schema-coercer.ts b/packages/openapi/src/adapters/standard/schema-coercer.ts deleted file mode 100644 index 9732d815..00000000 --- a/packages/openapi/src/adapters/standard/schema-coercer.ts +++ /dev/null @@ -1,20 +0,0 @@ -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/server/src/adapters/standard/handler.ts b/packages/server/src/adapters/standard/handler.ts index a501543d..6bbc91ea 100644 --- a/packages/server/src/adapters/standard/handler.ts +++ b/packages/server/src/adapters/standard/handler.ts @@ -1,7 +1,8 @@ -import type { HTTPPath } from '@orpc/contract' +import type { ErrorMap, HTTPPath, Meta, Schema } from '@orpc/contract' import type { Interceptor } from '@orpc/shared' import type { Context } from '../../context' import type { Plugin } from '../../plugins' +import type { CreateProcedureClientOptions } from '../../procedure-client' import type { Router } from '../../router' import type { StandardCodec, StandardMatcher, StandardRequest, StandardResponse } from './types' import { toORPCError } from '@orpc/contract' @@ -23,6 +24,11 @@ export type StandardHandleResult = { matched: true, response: StandardResponse } export type StandardHandlerInterceptorOptions = WellStandardHandleOptions & { request: StandardRequest } +export type WellCreateProcedureClientOptions = + CreateProcedureClientOptions & { + context: TContext + } + export interface StandardHandlerOptions { plugins?: Plugin[] @@ -53,21 +59,22 @@ export class StandardHandler { } handle(request: StandardRequest, ...[options]: StandardHandleRest): Promise { - const handleOptions = (options ?? {}) as WellStandardHandleOptions // options only undefined when all fields are optional so we can safely force it to have a context - handleOptions.context ??= {} as TContext // context is optional only when all fields are optional so we can safely force it to have a context - return intercept( this.options.interceptorsRoot ?? [], - { request, ...handleOptions }, - async (interceptorRootOptions) => { + { + request, + ...options, + context: options?.context ?? {} as TContext, // context is optional only when all fields are optional so we can safely force it to have a context + }, + async (interceptorOptions) => { try { return await intercept( this.options.interceptors ?? [], - interceptorRootOptions, - async ({ request, context }) => { - const method = request.method - const url = request.url - const pathname = `/${trim(url.pathname.replace(options?.prefix ?? '', ''), '/')}` as const + interceptorOptions, + async (interceptorOptions) => { + const method = interceptorOptions.request.method + const url = interceptorOptions.request.url + const pathname = `/${trim(url.pathname.replace(interceptorOptions.prefix ?? '', ''), '/')}` as const const match = await this.matcher.match(method, pathname) @@ -75,10 +82,14 @@ export class StandardHandler { return { matched: false, response: undefined } } - const client = createProcedureClient(match.procedure, { - context, + const clientOptions: WellCreateProcedureClientOptions = { + context: interceptorOptions.context, path: match.path, - }) + } + + this.plugin.beforeCreateProcedureClient(clientOptions, interceptorOptions) + + const client = createProcedureClient(match.procedure, clientOptions) const input = await this.codec.decode(request, match.params, match.procedure) diff --git a/packages/server/src/plugins/base.ts b/packages/server/src/plugins/base.ts index 42469c60..76ff7107 100644 --- a/packages/server/src/plugins/base.ts +++ b/packages/server/src/plugins/base.ts @@ -1,8 +1,13 @@ -import type { StandardHandlerOptions } from '../adapters/standard' +import type { StandardHandlerInterceptorOptions, StandardHandlerOptions, WellCreateProcedureClientOptions } from '../adapters/standard' import type { Context } from '../context' export interface Plugin { init?(options: StandardHandlerOptions): void + + beforeCreateProcedureClient?( + clientOptions: WellCreateProcedureClientOptions, + interceptorOptions: StandardHandlerInterceptorOptions + ): void } export class CompositePlugin implements Plugin { @@ -13,4 +18,13 @@ export class CompositePlugin implements Plugin, + interceptorOptions: StandardHandlerInterceptorOptions, + ): void { + for (const plugin of this.plugins) { + plugin.beforeCreateProcedureClient?.(clientOptions, interceptorOptions) + } + } } diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index aad4a88d..06af1628 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -100,19 +100,17 @@ export function createProcedureClient< const context = await value(options?.context ?? {}, callerOptions?.context) as TInitialContext const errors = createORPCErrorConstructorMap(procedure['~orpc'].errorMap) - const interceptorOptions: ProcedureClientInterceptorOptions = { - context, - input: input as SchemaInput, // input only optional when it undefinable so we can safely cast it - errors, - path, - procedure: procedure as AnyProcedure, - signal: callerOptions?.signal, - } - try { return await intercept( options?.interceptors ?? [], - interceptorOptions, + { + context, + input: input as SchemaInput, // input only optional when it undefinable so we can safely cast it + errors, + path, + procedure: procedure as AnyProcedure, + signal: callerOptions?.signal, + }, interceptorOptions => executeProcedureInternal(interceptorOptions.procedure, interceptorOptions), ) } diff --git a/packages/server/src/router-client.ts b/packages/server/src/router-client.ts index 4aea4a40..996e0094 100644 --- a/packages/server/src/router-client.ts +++ b/packages/server/src/router-client.ts @@ -17,20 +17,22 @@ export type RouterClient = TRouter ex [K in keyof TRouter]: TRouter[K] extends AnyRouter ? RouterClient : never } +export type CreateRouterClientRest = CreateProcedureClientRest< + TRouter extends Router ? UContext : never, + undefined, + undefined, + unknown, + ErrorMap, + Meta, + TClientContext +> + export function createRouterClient< TRouter extends AnyRouter, TClientContext, >( router: TRouter | Lazy, - ...rest: CreateProcedureClientRest< - TRouter extends Router ? UContext : never, - undefined, - undefined, - unknown, - ErrorMap, - Meta, - TClientContext - > + ...rest: CreateRouterClientRest ): RouterClient { if (isProcedure(router)) { const caller = createProcedureClient(router, ...rest) diff --git a/packages/shared/src/interceptor.ts b/packages/shared/src/interceptor.ts index 402ccd94..ef5355b1 100644 --- a/packages/shared/src/interceptor.ts +++ b/packages/shared/src/interceptor.ts @@ -93,18 +93,18 @@ export async function intercept { let index = 0 - const next = async (nextOptions: TOptions = options): Promise => { + const next = async (options: TOptions): Promise => { const interceptor = interceptors[index++] if (!interceptor) { - return await main(nextOptions) + return await main(options) } return await interceptor({ - ...nextOptions, - next, + ...options, + next: (newOptions: TOptions = options) => next(newOptions), }) } - return await next() + return await next(options) } diff --git a/packages/zod/package.json b/packages/zod/package.json index 55960271..e17886f9 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -42,7 +42,8 @@ "type:check": "tsc -b" }, "peerDependencies": { - "@orpc/openapi": "workspace:*" + "@orpc/openapi": "workspace:*", + "@orpc/server": "workspace:*" }, "dependencies": { "json-schema-typed": "^8.0.1", diff --git a/packages/zod/src/coercer.ts b/packages/zod/src/coercer.ts index 727fb6ac..5bbc4c60 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -1,5 +1,6 @@ -import type { Schema } from '@orpc/contract' -import type { SchemaCoercer } from '@orpc/openapi/standard' +import type { Context } from '@orpc/server' +import type { Plugin } from '@orpc/server/plugins' +import type { WellCreateProcedureClientOptions } from '@orpc/server/standard' import { guard } from '@orpc/shared' import { getCustomZodType } from '@orpc/zod' import { isPlainObject } from 'is-what' @@ -28,15 +29,21 @@ import { type ZodUnion, } from 'zod' -export class ZodCoercer implements SchemaCoercer { - coerce(schema: Schema, value: unknown): unknown { - if (!schema || schema['~standard'].vendor !== 'zod') { - return value - } +export class ZodAutoCoercePlugin implements Plugin { + beforeCreateProcedureClient(clientOptions: WellCreateProcedureClientOptions): void { + clientOptions.interceptors ??= [] + + clientOptions.interceptors.unshift((options) => { + const inputSchema = options.procedure['~orpc'].inputSchema + + if (!inputSchema || inputSchema['~standard'].vendor !== 'zod') { + return options.next() + } + + const coercedInput = zodCoerceInternal(inputSchema as ZodTypeAny, options.input, { bracketNotation: true }) - const zodSchema = schema as ZodTypeAny - const coerced = zodCoerceInternal(zodSchema, value, { bracketNotation: true }) - return coerced + return options.next({ ...options, input: coercedInput }) + }) } } diff --git a/packages/zod/tests/coercer.test.ts b/packages/zod/tests/coercer.test.ts index 1fc691b4..0b7f0a6c 100644 --- a/packages/zod/tests/coercer.test.ts +++ b/packages/zod/tests/coercer.test.ts @@ -1,28 +1,34 @@ import { OpenAPIHandler } from '@orpc/openapi/fetch' import { os } from '@orpc/server' import { z } from 'zod' -import { ZodCoercer } from '../src' +import { ZodAutoCoercePlugin } from '../src' beforeEach(() => { vi.clearAllMocks() }) -describe('zodCoercer', () => { +describe('zodAutoCoercePlugin', () => { it('should coerce input', async () => { const fn = vi.fn().mockReturnValue('__mocked__') const router = os.router({ ping: os - .input(z.object({ val: z.bigint() })) + .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, { - schemaCoercers: [ - new ZodCoercer(), + plugins: [ + new ZodAutoCoercePlugin(), ], }) - const { response } = await handler.handle(new Request('https://example.com/ping', { + const { response } = await handler.handle(new Request('https://example.com/ping/12345', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -33,6 +39,11 @@ describe('zodCoercer', () => { })) expect(response?.status).toBe(200) - expect(fn).toHaveBeenCalledWith(expect.objectContaining({ input: { val: 123n } })) + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ + input: { + params: { id: 12345 }, + body: { val: 123n }, + }, + })) }) }) diff --git a/playgrounds/contract-openapi/src/main.ts b/playgrounds/contract-openapi/src/main.ts index 5c345737..07642136 100644 --- a/playgrounds/contract-openapi/src/main.ts +++ b/playgrounds/contract-openapi/src/main.ts @@ -3,20 +3,20 @@ import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' -import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ZodAutoCoercePlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { contract } from './contract' import { router } from './router' import './polyfill' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), - ], interceptors: [ onError((error) => { console.error(error) }), ], + plugins: [ + new ZodAutoCoercePlugin(), + ], }) const rpcHandler = new RPCHandler(router, { diff --git a/playgrounds/expressjs/src/main.ts b/playgrounds/expressjs/src/main.ts index 2b169627..d0149e27 100644 --- a/playgrounds/expressjs/src/main.ts +++ b/playgrounds/expressjs/src/main.ts @@ -2,7 +2,7 @@ import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' -import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ZodAutoCoercePlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import express from 'express' import { router } from './router' import './polyfill' @@ -10,14 +10,14 @@ import './polyfill' const app = express() const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), - ], interceptors: [ onError((error) => { console.error(error) }), ], + plugins: [ + new ZodAutoCoercePlugin(), + ], }) app.use('/api/*', async (req, res, next) => { diff --git a/playgrounds/nextjs/src/app/api/[...rest]/route.ts b/playgrounds/nextjs/src/app/api/[...rest]/route.ts index 0b8fef71..6d81f49a 100644 --- a/playgrounds/nextjs/src/app/api/[...rest]/route.ts +++ b/playgrounds/nextjs/src/app/api/[...rest]/route.ts @@ -2,18 +2,18 @@ import { router } from '@/router' import { OpenAPIHandler } from '@orpc/openapi/next' import { onError } from '@orpc/server' import { serve } from '@orpc/server/next' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' import '../../../polyfill' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), - ], interceptors: [ onError((error) => { console.error(error) }), ], + plugins: [ + new ZodAutoCoercePlugin(), + ], }) export const { GET, POST, PUT, PATCH, DELETE } = serve(openAPIHandler, { diff --git a/playgrounds/nuxt/server/routes/api/[...rest].ts b/playgrounds/nuxt/server/routes/api/[...rest].ts index 43974dae..20553a29 100644 --- a/playgrounds/nuxt/server/routes/api/[...rest].ts +++ b/playgrounds/nuxt/server/routes/api/[...rest].ts @@ -1,17 +1,17 @@ import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' -import { ZodCoercer } from '@orpc/zod' +import { ZodAutoCoercePlugin } from '@orpc/zod' import { router } from '~/server/router' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), - ], interceptors: [ onError((error) => { console.error(error) }), ], + plugins: [ + new ZodAutoCoercePlugin(), + ], }) export default defineEventHandler(async (event) => { diff --git a/playgrounds/openapi/src/main.ts b/playgrounds/openapi/src/main.ts index ee2c2f4b..3636bcc3 100644 --- a/playgrounds/openapi/src/main.ts +++ b/playgrounds/openapi/src/main.ts @@ -4,14 +4,11 @@ import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import { CORSPlugin, ResponseHeadersPlugin } from '@orpc/server/plugins' -import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ZodAutoCoercePlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from './router' import './polyfill' const openAPIHandler = new OpenAPIHandler(router, { - schemaCoercers: [ - new ZodCoercer(), - ], interceptors: [ onError((error) => { console.error(error) @@ -22,6 +19,7 @@ const openAPIHandler = new OpenAPIHandler(router, { origin: 'http://localhost:3000', }), new ResponseHeadersPlugin(), + new ZodAutoCoercePlugin(), ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae460712..dcf394b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ importers: '@orpc/openapi': specifier: workspace:* version: link:../openapi + '@orpc/server': + specifier: workspace:* + version: link:../server json-schema-typed: specifier: ^8.0.1 version: 8.0.1