diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 806d6887d..42ffacdf6 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -137,6 +137,7 @@ export default withMermaid(defineConfig({ { text: 'CORS', link: '/docs/plugins/cors' }, { text: 'Request Headers', link: '/docs/plugins/request-headers' }, { text: 'Response Headers', link: '/docs/plugins/response-headers' }, + { text: 'Response Validation', link: '/docs/plugins/response-validation' }, { text: 'Hibernation', link: '/docs/plugins/hibernation' }, { text: 'Dedupe Requests', link: '/docs/plugins/dedupe-requests' }, { text: 'Batch Requests', link: '/docs/plugins/batch-requests' }, @@ -275,6 +276,7 @@ export default withMermaid(defineConfig({ collapsed: true, items: [ { text: 'Customizing Error Response', link: '/docs/openapi/advanced/customizing-error-response' }, + { text: 'Expanding Type Support for OpenAPI Link', link: '/docs/openapi/advanced/expanding-type-support-for-openapi-link' }, { text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' }, { text: 'Redirect Response', link: '/docs/openapi/advanced/redirect-response' }, ], diff --git a/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md b/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md new file mode 100644 index 000000000..7afd3acbc --- /dev/null +++ b/apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md @@ -0,0 +1,73 @@ +--- +title: Expanding Type Support for OpenAPI Link +description: Learn how to extend OpenAPILink to support additional data types beyond JSON's native capabilities using the Response Validation Plugin and schema coercion. +--- + +# Expanding Type Support for OpenAPI Link + +This guide will show you how to extend [OpenAPILink](/docs/openapi/client/openapi-link) to support additional data types beyond JSON's native capabilities using the [Response Validation Plugin](/docs/plugins/response-validation). + +## How It Works + +To enable this functionality, you need to customize your output schema with proper coercion logic. + +**Why?** OpenAPI response data only represents JSON's native capabilities. We use schema coercion logic in output schemas to convert the data to the desired type. + +::: warning +Beyond JSON limitations, outputs containing `Blob` or `File` types (outside the root level) also face [Bracket Notation](/docs/openapi/bracket-notation#limitations) limitations. +::: + +```ts +const contract = oc.output(z.object({ + date: z.coerce.date(), // [!code highlight] + bigint: z.coerce.bigint(), // [!code highlight] +})) + +const procedure = implement(contract).handler(() => ({ + date: new Date(), + bigint: 123n, +})) +``` + +On the client side, you'll receive the output like this: + +```ts +const beforeValidation = { + date: '2025-09-01T07:24:39.000Z', + bigint: '123' +} +``` + +Since your output schema contains coercion logic, the Response Validation Plugin will convert the data to the desired type after validation. + +```ts +const afterValidation = { + date: new Date('2025-09-01T07:24:39.000Z'), + bigint: 123n +} +``` + +::: warning +To support more types than those in [OpenAPI Handler](/docs/openapi/openapi-handler#supported-data-types), you must first extend the [OpenAPI JSON Serializer](/docs/openapi/advanced/openapi-json-serializer) first. +::: + +## Setup + +After understanding how it works and expanding output schemas with coercion logic, you only need to set up the [Response Validation Plugin](/docs/plugins/response-validation) and remove the `JsonifiedClient` wrapper. + +```diff + import type { ContractRouterClient } from '@orpc/contract' + import { createORPCClient } from '@orpc/client' + import { OpenAPILink } from '@orpc/openapi-client/fetch' + import { ResponseValidationPlugin } from '@orpc/contract/plugins' + + const link = new OpenAPILink(contract, { + url: 'http://localhost:3000/api', + plugins: [ ++ new ResponseValidationPlugin(contract), + ] + }) + +-const client: JsonifiedClient> = createORPCClient(link) ++const client: ContractRouterClient = createORPCClient(link) +``` diff --git a/apps/content/docs/openapi/client/openapi-link.md b/apps/content/docs/openapi/client/openapi-link.md index e335873d7..bfab7eaa0 100644 --- a/apps/content/docs/openapi/client/openapi-link.md +++ b/apps/content/docs/openapi/client/openapi-link.md @@ -71,7 +71,7 @@ const client: JsonifiedClient> = createORP ``` :::warning -Wrap your client with `JsonifiedClient` to ensure it accurately reflects the server responses. +Due to JSON limitations, you must wrap your client with `JsonifiedClient` to ensure type safety. Alternatively, follow the [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link) guide to preserve original types without the wrapper. ::: ## Limitations diff --git a/apps/content/docs/plugins/client-retry.md b/apps/content/docs/plugins/client-retry.md index 3e63dbbda..d846251b9 100644 --- a/apps/content/docs/plugins/client-retry.md +++ b/apps/content/docs/plugins/client-retry.md @@ -41,6 +41,10 @@ const link = new RPCLink({ const client: RouterClient = createORPCClient(link) ``` +::: info +The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. +::: + ## Usage ```ts twoslash diff --git a/apps/content/docs/plugins/response-validation.md b/apps/content/docs/plugins/response-validation.md new file mode 100644 index 000000000..61d33b560 --- /dev/null +++ b/apps/content/docs/plugins/response-validation.md @@ -0,0 +1,52 @@ +--- +title: Response Validation Plugin +description: A plugin that validates server responses against the contract schema to ensure that the data returned from your server matches the expected types defined in your contract. +--- + +# Response Validation Plugin + +The **Response Validation Plugin** validates server responses against your contract schema, ensuring that data returned from your server matches the expected types defined in your contract. + +::: info +This plugin is best suited for [Contract-First Development](/docs/contract-first/define-contract). [Minified Contract](/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client) is **not supported** because it removes the schema from the contract. +::: + +## Setup + +```ts twoslash +import { contract } from './shared/planet' +import { createORPCClient } from '@orpc/client' +import type { ContractRouterClient } from '@orpc/contract' +// ---cut--- +import { RPCLink } from '@orpc/client/fetch' +import { ResponseValidationPlugin } from '@orpc/contract/plugins' + +const link = new RPCLink({ + url: 'http://localhost:3000/rpc', + plugins: [ + new ResponseValidationPlugin(contract), + ], +}) + +const client: ContractRouterClient = createORPCClient(link) +``` + +::: info +The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations. +::: + +## Limitations + +Schemas that transform data into different types than the expected schema types are not supported. + +**Why?** Consider this example schema that accepts a `number` and transforms it into a `string` after validation: + +```ts +const unsupported = z.number().transform(value => value.toString()) +``` + +When the server validates output, it transforms the `number` into a `string`. The client receives a `string`, but the `string` no longer matches the original schema, causing validation to fail. + +## Advanced Usage + +Beyond response validation, this plugin also serves special purposes such as [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link). diff --git a/packages/contract/package.json b/packages/contract/package.json index be23104d4..171a4c8d2 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -19,11 +19,17 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" + }, + "./plugins": { + "types": "./dist/plugins/index.d.mts", + "import": "./dist/plugins/index.mjs", + "default": "./dist/plugins/index.mjs" } } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./plugins": "./src/plugins/index.ts" }, "files": [ "dist" diff --git a/packages/contract/src/error.test.ts b/packages/contract/src/error.test.ts index 12a241e6e..071837f95 100644 --- a/packages/contract/src/error.test.ts +++ b/packages/contract/src/error.test.ts @@ -1,5 +1,8 @@ +import type { ErrorMap } from './error' +import { ORPCError } from '@orpc/client' +import z from 'zod' import { baseErrorMap } from '../tests/shared' -import { mergeErrorMap, ValidationError } from './error' +import { mergeErrorMap, validateORPCError, ValidationError } from './error' it('validationError', () => { const error = new ValidationError({ message: 'message', issues: [{ message: 'message' }] }) @@ -13,3 +16,71 @@ it('mergeErrorMap', () => { { OVERRIDE: {}, INVALID: {}, BASE: baseErrorMap.BASE }, ) }) + +describe('validateORPCError', () => { + const errors: ErrorMap = { + BAD_GATEWAY: { + data: z.object({ + value: z.string().transform(v => Number.parseInt(v)), + }), + }, + CONFLICT: { + status: 483, + }, + } + + it('ignore not-match errors when defined=false', async () => { + const e1 = new ORPCError('BAD_GATEWAY', { status: 501, data: { value: '123' } }) + expect(await validateORPCError(errors, e1)).toBe(e1) + + const e2 = new ORPCError('NOT_FOUND') + expect(await validateORPCError(errors, e2)).toBe(e2) + + const e3 = new ORPCError('BAD_GATEWAY', { data: 'invalid' }) + expect(await validateORPCError(errors, e3)).toBe(e3) + + const e4 = new ORPCError('CONFLICT') + expect(await validateORPCError(errors, e4)).toBe(e4) + }) + + it('modify not-match errors when defined=true', async () => { + const e1 = new ORPCError('BAD_GATEWAY', { defined: true, status: 501 }) + const v1 = await validateORPCError(errors, e1) + expect(v1).not.toBe(e1) + expect({ ...v1 }).toEqual({ ...e1, defined: false }) + + const e2 = new ORPCError('NOT_FOUND', { defined: true }) + const v2 = await validateORPCError(errors, e2) + expect(v2).not.toBe(e2) + expect({ ...v2 }).toEqual({ ...e2, defined: false }) + + const e3 = new ORPCError('BAD_GATEWAY', { defined: true, data: 'invalid' }) + const v3 = await validateORPCError(errors, e3) + expect(v3).not.toBe(e3) + expect({ ...v3 }).toEqual({ ...e3, defined: false }) + + const e4 = new ORPCError('CONFLICT', { defined: true }) + const v4 = await validateORPCError(errors, e4) + expect(v4).not.toBe(e4) + expect({ ...v4 }).toEqual({ ...e4, defined: false }) + }) + + it('ignore match errors when defined=true and data schema is undefined', async () => { + const e1 = new ORPCError('CONFLICT', { defined: true, status: 483 }) + expect(await validateORPCError(errors, e1)).toBe(e1) + }) + + it('return new error when defined=true and data schema is undefined with match error', async () => { + const e1 = new ORPCError('CONFLICT', { status: 483 }) + const v1 = await validateORPCError(errors, e1) + expect(v1).not.toBe(e1) + expect({ ...v1 }).toEqual({ ...e1, defined: true }) + }) + + it('return new with defined=true and validated data with match errors', async () => { + const e1 = new ORPCError('BAD_GATEWAY', { data: { value: '123' } }) + const v1 = await validateORPCError(errors, e1) + expect(v1).not.toBe(e1) + expect({ ...v1 }).toEqual({ ...e1, defined: true, data: { value: 123 } }) + }) +}) diff --git a/packages/contract/src/error.ts b/packages/contract/src/error.ts index 000729b6b..d717d5370 100644 --- a/packages/contract/src/error.ts +++ b/packages/contract/src/error.ts @@ -1,6 +1,7 @@ -import type { ORPCError, ORPCErrorCode } from '@orpc/client' +import type { ORPCErrorCode } from '@orpc/client' import type { ThrowableError } from '@orpc/shared' import type { AnySchema, InferSchemaOutput, Schema, SchemaIssue } from './schema' +import { fallbackORPCErrorStatus, ORPCError } from '@orpc/client' export interface ValidationErrorOptions extends ErrorOptions { message: string @@ -53,3 +54,30 @@ export type ORPCErrorFromErrorMap = { }[keyof TErrorMap] export type ErrorFromErrorMap = ORPCErrorFromErrorMap | ThrowableError + +export async function validateORPCError(map: ErrorMap, error: ORPCError): Promise> { + const { code, status, message, data, cause, defined } = error + const config = map?.[error.code] + + if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) { + return defined + ? new ORPCError(code, { defined: false, status, message, data, cause }) + : error + } + + if (!config.data) { + return defined + ? error + : new ORPCError(code, { defined: true, status, message, data, cause }) + } + + const validated = await config.data['~standard'].validate(error.data) + + if (validated.issues) { + return defined + ? new ORPCError(code, { defined: false, status, message, data, cause }) + : error + } + + return new ORPCError(code, { defined: true, status, message, data: validated.value, cause }) +} diff --git a/packages/contract/src/plugins/index.test.ts b/packages/contract/src/plugins/index.test.ts new file mode 100644 index 000000000..417e91c2b --- /dev/null +++ b/packages/contract/src/plugins/index.test.ts @@ -0,0 +1,3 @@ +it('exports something', async () => { + expect(await import('./index')).toHaveProperty('ResponseValidationPlugin') +}) diff --git a/packages/contract/src/plugins/index.ts b/packages/contract/src/plugins/index.ts new file mode 100644 index 000000000..4058a4ae8 --- /dev/null +++ b/packages/contract/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './response-validation' diff --git a/packages/contract/src/plugins/response-validation.test.ts b/packages/contract/src/plugins/response-validation.test.ts new file mode 100644 index 000000000..e57d46a5c --- /dev/null +++ b/packages/contract/src/plugins/response-validation.test.ts @@ -0,0 +1,137 @@ +import { ORPCError } from '@orpc/client' +import { StandardLink } from '@orpc/client/standard' +import * as z from 'zod' +import { validateORPCError, ValidationError } from '../error' +import { ContractProcedure } from '../procedure' +import { ResponseValidationPlugin } from './response-validation' + +vi.mock('../error', async original => ({ + ...await original(), + validateORPCError: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('responseValidationPlugin', () => { + const schema = z.object({ + value: z.string().or(z.number()).transform(v => Number.parseInt(v.toString())), + }) + + const procedure = new ContractProcedure({ + outputSchema: schema, + errorMap: { + TEST: { + data: schema, + }, + }, + meta: {}, + route: {}, + }) + + const withoutOutputSchemaProcedure = new ContractProcedure({ + errorMap: {}, + meta: {}, + route: {}, + }) + + const contract = { + procedure, + nested: { + procedure, + }, + withoutOutputSchema: withoutOutputSchemaProcedure, + } + + const codec = { + decode: vi.fn(), + encode: vi.fn(), + } + + const client = { + call: vi.fn(), + } + + const interceptor = vi.fn(({ next }) => next()) + + const link = new StandardLink(codec, client, { + plugins: [ + new ResponseValidationPlugin(contract), + ], + // ResponseValidationPlugin should execute before user defined interceptors + interceptors: [interceptor], + }) + + describe('validate output', async () => { + it('procedure with output schema', async () => { + codec.decode.mockResolvedValueOnce({ value: '123' }) + + const output = await link.call(['procedure'], {}, { context: {} }) + + expect(output).toEqual({ value: 123 }) + expect(await interceptor.mock.results[0]?.value).toEqual({ value: 123 }) + }) + + it('procedure without output schema', async () => { + codec.decode.mockResolvedValueOnce('anything') + + const output = await link.call(['withoutOutputSchema'], {}, { context: {} }) + + expect(output).toEqual('anything') + expect(await interceptor.mock.results[0]?.value).toEqual('anything') + }) + + it('on error case', async () => { + codec.decode.mockResolvedValueOnce('invalid') + + await expect(link.call(['procedure'], {}, { context: {} })).rejects.toSatisfy((e) => { + expect(e).toBeInstanceOf(ValidationError) + expect(e.message).toBe('Server response output does not match expected schema') + expect(e.issues).toBeDefined() + expect(e.data).toEqual('invalid') + + return true + }) + + await expect(interceptor.mock.results[0]?.value).rejects.toSatisfy((e) => { + expect(e).toBeInstanceOf(ValidationError) + expect(e.message).toBe('Server response output does not match expected schema') + expect(e.issues).toBeDefined() + expect(e.data).toEqual('invalid') + + return true + }) + }) + }) + + describe('validate error', () => { + it('with ORPCError', async () => { + const error = new ORPCError('TEST', { message: 'test', defined: true }) + codec.decode.mockRejectedValueOnce(error) + + const error2 = new ORPCError('TEST', { message: 'test' }) + vi.mocked(validateORPCError).mockResolvedValueOnce(error2) + + await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error2) + await expect(interceptor.mock.results[0]?.value).rejects.toBe(error2) + + expect(validateORPCError).toHaveBeenCalledWith(contract.nested.procedure['~orpc'].errorMap, error) + }) + + it('without ORPCError', async () => { + const error = new Error('test') + codec.decode.mockRejectedValueOnce(error) + + await expect(link.call(['nested', 'procedure'], {}, { context: {} })).rejects.toBe(error) + await expect(interceptor.mock.results[0]?.value).rejects.toBe(error) + + expect(validateORPCError).not.toHaveBeenCalled() + }) + }) + + it('throw if not find matching contract', async () => { + await expect(link.call(['not', 'found'], {}, { context: {} })).rejects.toThrow('[ResponseValidationPlugin] no valid procedure found at path "not.found", this may happen when the contract router is not properly configured.') + await expect(interceptor.mock.results[0]?.value).rejects.toThrow('[ResponseValidationPlugin] no valid procedure found at path "not.found", this may happen when the contract router is not properly configured.') + }) +}) diff --git a/packages/contract/src/plugins/response-validation.ts b/packages/contract/src/plugins/response-validation.ts new file mode 100644 index 000000000..a78ac0e87 --- /dev/null +++ b/packages/contract/src/plugins/response-validation.ts @@ -0,0 +1,64 @@ +import type { ClientContext } from '@orpc/client' +import type { StandardLinkOptions, StandardLinkPlugin } from '@orpc/client/standard' +import type { AnyContractRouter } from '../router' +import { ORPCError } from '@orpc/client' +import { get } from '@orpc/shared' +import { validateORPCError, ValidationError } from '../error' +import { isContractProcedure } from '../procedure' + +/** + * A link plugin that validates server responses against your contract schema, + * ensuring that data returned from your server matches the expected types defined in your contract. + * + * - Throws `ValidationError` if output doesn't match the expected schema + * - Converts mismatched defined errors to normal `ORPCError` instances + * + * @see {@link https://orpc.unnoq.com/docs/plugins/response-validation Response Validation Plugin Docs} + */ +export class ResponseValidationPlugin implements StandardLinkPlugin { + constructor( + private readonly contract: AnyContractRouter, + ) {} + + order = 1_500_000 // make sure run before DurableEventIteratorLinkPlugin + + init(options: StandardLinkOptions): void { + options.interceptors ??= [] + + options.interceptors.push(async ({ next, path }) => { + const procedure = get(this.contract, path) + + if (!isContractProcedure(procedure)) { + throw new Error(`[ResponseValidationPlugin] no valid procedure found at path "${path.join('.')}", this may happen when the contract router is not properly configured.`) + } + + try { + const output = await next() + const outputSchema = procedure['~orpc'].outputSchema + + if (!outputSchema) { + return output + } + + const result = await outputSchema['~standard'].validate(output) + + if (result.issues) { + throw new ValidationError({ + message: 'Server response output does not match expected schema', + issues: result.issues, + data: output, + }) + } + + return result.value + } + catch (e) { + if (e instanceof ORPCError) { + throw await validateORPCError(procedure['~orpc'].errorMap, e) + } + + throw e + } + }) + } +} diff --git a/packages/server/src/error.test.ts b/packages/server/src/error.test.ts index 787b82458..e60b9d9ba 100644 --- a/packages/server/src/error.test.ts +++ b/packages/server/src/error.test.ts @@ -1,8 +1,6 @@ -import type { ErrorMap } from '@orpc/contract' import { ORPCError } from '@orpc/client' -import * as z from 'zod' import { outputSchema } from '../../contract/tests/shared' -import { createORPCErrorConstructorMap, validateORPCError } from './error' +import { createORPCErrorConstructorMap } from './error' describe('createORPCErrorConstructorMap', () => { const errors = { @@ -51,71 +49,3 @@ describe('createORPCErrorConstructorMap', () => { expect(constructors[Symbol('something')]).toBeUndefined() }) }) - -describe('validateORPCError', () => { - const errors: ErrorMap = { - BAD_GATEWAY: { - data: z.object({ - value: z.string().transform(v => Number.parseInt(v)), - }), - }, - CONFLICT: { - status: 483, - }, - } - - it('ignore not-match errors when defined=false', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { status: 501, data: { value: '123' } }) - expect(await validateORPCError(errors, e1)).toBe(e1) - - const e2 = new ORPCError('NOT_FOUND') - expect(await validateORPCError(errors, e2)).toBe(e2) - - const e3 = new ORPCError('BAD_GATEWAY', { data: 'invalid' }) - expect(await validateORPCError(errors, e3)).toBe(e3) - - const e4 = new ORPCError('CONFLICT') - expect(await validateORPCError(errors, e4)).toBe(e4) - }) - - it('modify not-match errors when defined=true', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { defined: true, status: 501 }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: false }) - - const e2 = new ORPCError('NOT_FOUND', { defined: true }) - const v2 = await validateORPCError(errors, e2) - expect(v2).not.toBe(e2) - expect({ ...v2 }).toEqual({ ...e2, defined: false }) - - const e3 = new ORPCError('BAD_GATEWAY', { defined: true, data: 'invalid' }) - const v3 = await validateORPCError(errors, e3) - expect(v3).not.toBe(e3) - expect({ ...v3 }).toEqual({ ...e3, defined: false }) - - const e4 = new ORPCError('CONFLICT', { defined: true }) - const v4 = await validateORPCError(errors, e4) - expect(v4).not.toBe(e4) - expect({ ...v4 }).toEqual({ ...e4, defined: false }) - }) - - it('ignore match errors when defined=true and data schema is undefined', async () => { - const e1 = new ORPCError('CONFLICT', { defined: true, status: 483 }) - expect(await validateORPCError(errors, e1)).toBe(e1) - }) - - it('return new error when defined=true and data schema is undefined with match error', async () => { - const e1 = new ORPCError('CONFLICT', { status: 483 }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: true }) - }) - - it('return new with defined=true and validated data with match errors', async () => { - const e1 = new ORPCError('BAD_GATEWAY', { data: { value: '123' } }) - const v1 = await validateORPCError(errors, e1) - expect(v1).not.toBe(e1) - expect({ ...v1 }).toEqual({ ...e1, defined: true, data: { value: 123 } }) - }) -}) diff --git a/packages/server/src/error.ts b/packages/server/src/error.ts index 12be55675..2ba95466a 100644 --- a/packages/server/src/error.ts +++ b/packages/server/src/error.ts @@ -1,7 +1,7 @@ import type { ORPCErrorCode, ORPCErrorOptions } from '@orpc/client' import type { ErrorMap, ErrorMapItem, InferSchemaInput } from '@orpc/contract' import type { MaybeOptionalOptions } from '@orpc/shared' -import { fallbackORPCErrorStatus, ORPCError } from '@orpc/client' +import { ORPCError } from '@orpc/client' import { resolveMaybeOptionalOptions } from '@orpc/shared' export type ORPCErrorConstructorMapItemOptions = Omit, 'defined' | 'status'> @@ -46,30 +46,3 @@ export function createORPCErrorConstructorMap(errors: T): OR return proxy as any } - -export async function validateORPCError(map: ErrorMap, error: ORPCError): Promise> { - const { code, status, message, data, cause, defined } = error - const config = map?.[error.code] - - if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) { - return defined - ? new ORPCError(code, { defined: false, status, message, data, cause }) - : error - } - - if (!config.data) { - return defined - ? error - : new ORPCError(code, { defined: true, status, message, data, cause }) - } - - const validated = await config.data['~standard'].validate(error.data) - - if (validated.issues) { - return defined - ? new ORPCError(code, { defined: false, status, message, data, cause }) - : error - } - - return new ORPCError(code, { defined: true, status, message, data: validated.value, cause }) -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3e9d732cb..6736fac22 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,7 +23,7 @@ export * from './router-utils' export { isDefinedError, ORPCError, safe } from '@orpc/client' export type { ClientContext, HTTPMethod, HTTPPath } from '@orpc/client' -export { eventIterator, type, ValidationError } from '@orpc/contract' +export { eventIterator, type, validateORPCError, ValidationError } from '@orpc/contract' export type { ContractProcedure, ContractProcedureDef, diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index 109034d3e..1df95b782 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -1,14 +1,19 @@ import { ORPCError } from '@orpc/client' +import { validateORPCError } from '@orpc/contract' import { HibernationEventIterator } from '@orpc/standard-server' import * as z from 'zod' -import { createORPCErrorConstructorMap, validateORPCError } from './error' +import { createORPCErrorConstructorMap } from './error' import { isLazy, lazy, unlazy } from './lazy' import { Procedure } from './procedure' import { createProcedureClient } from './procedure-client' -vi.mock('./error', async origin => ({ +vi.mock('@orpc/contract', async origin => ({ ...await origin(), validateORPCError: vi.fn((map, error) => error), +})) + +vi.mock('./error', async origin => ({ + ...await origin(), createORPCErrorConstructorMap: vi.fn(), })) diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index ab10c71f8..4390cc0d8 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -6,11 +6,11 @@ import type { ORPCErrorConstructorMap } from './error' import type { Lazyable } from './lazy' import type { AnyProcedure, Procedure, ProcedureHandlerOptions } from './procedure' import { mapEventIterator, ORPCError } from '@orpc/client' -import { ValidationError } from '@orpc/contract' +import { validateORPCError, ValidationError } from '@orpc/contract' import { asyncIteratorWithSpan, intercept, isAsyncIteratorObject, resolveMaybeOptionalOptions, runWithSpan, toArray, value } from '@orpc/shared' import { HibernationEventIterator } from '@orpc/standard-server' import { mergeCurrentContext } from './context' -import { createORPCErrorConstructorMap, validateORPCError } from './error' +import { createORPCErrorConstructorMap } from './error' import { unlazy } from './lazy' import { middlewareOutputFn } from './middleware'