Skip to content

feat(contract): Response Validation Plugin#953

Merged
dinwwwh merged 5 commits intomainfrom
feat/contract/response-validation-plugin
Sep 1, 2025
Merged

feat(contract): Response Validation Plugin#953
dinwwwh merged 5 commits intomainfrom
feat/contract/response-validation-plugin

Conversation

@dinwwwh
Copy link
Copy Markdown
Member

@dinwwwh dinwwwh commented Sep 1, 2025

Closes: https://github.com/unnoq/orpc/issues/947

Summary by CodeRabbit

  • New Features

    • Response Validation Plugin added to validate and coerce server responses; validateORPCError implemented and made available from the contract package (re-exported by server).
  • Documentation

    • New docs: Response Validation Plugin and Expanding Type Support for OpenAPI Link.
    • OpenAPILink guidance updated on JSON limitations and alternatives.
    • Client Retry docs clarified and site navigation updated.
  • Chores / Tests

    • Public plugin entrypoint exposed and comprehensive tests added; tests adjusted to reflect export relocations.

@vercel
Copy link
Copy Markdown

vercel Bot commented Sep 1, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
orpc Ready Ready Preview Comment Sep 1, 2025 9:02am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Sep 1, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds a ResponseValidationPlugin and its tests, exposes plugins from the contract package, moves validateORPCError into the contract package and re-exports it from server, removes the server-local validator, and updates site navigation and docs for response validation and OpenAPI type expansion.

Changes

Cohort / File(s) Summary
Docs navigation
apps/content/.vitepress/config.ts
Adds sidebar entries for "Response Validation" and "Expanding Type Support for OpenAPI Link".
Docs pages
apps/content/docs/plugins/response-validation.md, apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md, apps/content/docs/openapi/client/openapi-link.md, apps/content/docs/plugins/client-retry.md
New Response Validation plugin doc; new OpenAPI type-expansion guide; updated OpenAPILink setup wording; added informational admonitions in client-retry docs.
Contract package exports
packages/contract/package.json
Adds public ./plugins export and corresponding publishConfig entries for plugin artifacts.
Contract: error API & tests
packages/contract/src/error.ts, packages/contract/src/error.test.ts
Adds validateORPCError to validate/normalize ORPCError against an ErrorMap and accompanying tests.
Contract: plugins and tests
packages/contract/src/plugins/index.ts, packages/contract/src/plugins/response-validation.ts, packages/contract/src/plugins/response-validation.test.ts, packages/contract/src/plugins/index.test.ts
Adds ResponseValidationPlugin (interceptor validating outputs and remapping ORPCError via validateORPCError), a plugins export barrel, and tests for behavior and export.
Server: remove local validator & tests updated
packages/server/src/error.ts, packages/server/src/error.test.ts
Removes server-local validateORPCError implementation and updates tests to new error descriptor behavior.
Server: re-export and usage updates
packages/server/src/index.ts, packages/server/src/procedure-client.ts, packages/server/src/procedure-client.test.ts
Re-exports validateORPCError from @orpc/contract; imports/usage in procedure-client and its tests updated to use contract-provided validator.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant L as StandardLink
  participant P as ResponseValidationPlugin
  participant N as Next Link/Transport
  participant S as Server

  C->>L: request(path, input)
  note right of L: plugins/interceptors run in order
  L->>P: interceptor(request)
  P->>P: resolve contract procedure by path
  P->>N: next(request)
  N->>S: send(input)
  S-->>N: output or ORPCError
  alt output present
    P->>P: validate outputSchema (if present)
    alt valid
      P-->>L: return validated value
    else invalid
      P-->>L: throw ValidationError (includes issues + raw data)
    end
  else ORPCError returned
    P->>P: validateORPCError(errorMap, error)
    P-->>L: throw mapped ORPCError
  end
  L-->>C: result or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • unnoq/orpc#344 — Related to plugin interfaces and ordering that the ResponseValidationPlugin depends on.
  • unnoq/orpc#306 — Overlaps documentation wording changes to apps/content/docs/openapi/client/openapi-link.md.
  • unnoq/orpc#194 — Also modifies server export surfaces; related to re-exporting contract-provided symbols.

Suggested labels

size:XXL

Poem

I nibble at schemas, hop through the code,
Sniffing bad outputs where wild bytes goad.
I validate, coerce, then bounce errors back right—
Breadcrumb docs glow in the soft twilight.
Hooray for plugins! 🐇✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/contract/response-validation-plugin

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Sep 1, 2025
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of Changes

Hello @unnoq, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the client-side capabilities for ensuring data integrity by introducing a robust response validation mechanism. It allows developers to enforce contract schemas on incoming server responses, leading to more reliable and type-safe client applications. A key benefit is the improved handling of complex data types with OpenAPI links, streamlining data transformation and reducing boilerplate.

Highlights

  • New Response Validation Plugin: Introduced a new client-side plugin, ResponseValidationPlugin, which validates server responses against the defined contract schema. This ensures data integrity and type safety on the client side.
  • Enhanced OpenAPI Type Support: Added functionality and documentation to allow OpenAPILink to support additional data types (like Date and BigInt) beyond JSON's native capabilities by leveraging schema coercion and the new Response Validation Plugin, potentially removing the need for a JsonifiedClient wrapper in certain scenarios.
  • Error Validation Logic Refactoring: The validateORPCError utility function has been refactored and moved from the @orpc/server package to the @orpc/contract package. This centralizes error validation logic within the contract definition layer, making it accessible for client-side plugins like the new Response Validation Plugin.
  • Comprehensive Documentation: New documentation pages have been added for the Response Validation Plugin and for expanding type support in OpenAPI Link, providing clear guidance on setup, usage, and limitations.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable ResponseValidationPlugin that enables client-side validation of server responses against the contract. This is a great addition for ensuring type safety, especially when using OpenAPILink with data types not native to JSON. The accompanying documentation is clear and the refactoring of validateORPCError into the @orpc/contract package is a logical improvement. I have one suggestion to enhance the readability of the new error validation logic.

Comment thread packages/contract/src/error.ts
@codecov
Copy link
Copy Markdown

codecov Bot commented Sep 1, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Sep 1, 2025

More templates

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@953

@orpc/client

npm i https://pkg.pr.new/@orpc/client@953

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@953

@orpc/experimental-durable-event-iterator

npm i https://pkg.pr.new/@orpc/experimental-durable-event-iterator@953

@orpc/hey-api

npm i https://pkg.pr.new/@orpc/hey-api@953

@orpc/interop

npm i https://pkg.pr.new/@orpc/interop@953

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@953

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@953

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@953

@orpc/openapi-client

npm i https://pkg.pr.new/@orpc/openapi-client@953

@orpc/otel

npm i https://pkg.pr.new/@orpc/otel@953

@orpc/react

npm i https://pkg.pr.new/@orpc/react@953

@orpc/react-query

npm i https://pkg.pr.new/@orpc/react-query@953

@orpc/experimental-react-swr

npm i https://pkg.pr.new/@orpc/experimental-react-swr@953

@orpc/server

npm i https://pkg.pr.new/@orpc/server@953

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@953

@orpc/solid-query

npm i https://pkg.pr.new/@orpc/solid-query@953

@orpc/standard-server

npm i https://pkg.pr.new/@orpc/standard-server@953

@orpc/standard-server-aws-lambda

npm i https://pkg.pr.new/@orpc/standard-server-aws-lambda@953

@orpc/standard-server-fetch

npm i https://pkg.pr.new/@orpc/standard-server-fetch@953

@orpc/standard-server-node

npm i https://pkg.pr.new/@orpc/standard-server-node@953

@orpc/standard-server-peer

npm i https://pkg.pr.new/@orpc/standard-server-peer@953

@orpc/svelte-query

npm i https://pkg.pr.new/@orpc/svelte-query@953

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@953

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@953

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@953

@orpc/vue-colada

npm i https://pkg.pr.new/@orpc/vue-colada@953

@orpc/vue-query

npm i https://pkg.pr.new/@orpc/vue-query@953

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@953

commit: f30b1ba

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/content/docs/openapi/client/openapi-link.md (1)

90-90: Fix typo and MIME example.

Replace “whe file” with “when the file” and correct the MIME type.

-`OpenAPILink` requires access to the `Content-Disposition` to distinguish file responses from other responses whe file has a common MIME type like `application/json`, `plain/text`, etc. To enable this, include `Content-Disposition` in your CORS policy's `Access-Control-Expose-Headers`:
+`OpenAPILink` requires access to the `Content-Disposition` to distinguish file responses from other responses when the file has a common MIME type like `application/json`, `text/plain`, etc. To enable this, include `Content-Disposition` in your CORS policy's `Access-Control-Expose-Headers`:
packages/server/src/error.ts (1)

24-47: Avoid accidental “thenable” behavior and tighten defined-check; optional caching for stability.

  • Proxy currently returns a callable for any string key, including "then", which can make the proxy appear thenable and break awaits/Promise.resolve. Guard it.
  • Use hasOwn to avoid prototype hits (e.g., toString) falsely marking an error as defined.
  • Optionally cache per-code constructors to keep referential stability and reduce allocations.
   const proxy = new Proxy(errors, {
     get(target, code) {
       if (typeof code !== 'string') {
         return Reflect.get(target, code)
       }
 
-      const item: ORPCErrorConstructorMapItem<string, unknown> = (...rest) => {
+      // prevent thenable assimilation (e.g., Promise.resolve(proxy))
+      if (code === 'then') return undefined as any
+
+      // optional: cache to keep stable references and reduce allocations
+      const __cache = (__cacheMap as Map<string, any>) ?? new Map<string, any>()
+      ;(__cacheMap as any) = __cache
+      const cached = __cache.get(code)
+      if (cached) return cached
+
+      const has = Object.hasOwn
+        ? Object.hasOwn(errors as object, code)
+        : Object.prototype.hasOwnProperty.call(errors, code)
+
+      const item: ORPCErrorConstructorMapItem<string, unknown> = (...rest) => {
         const options = resolveMaybeOptionalOptions(rest)
-        const config = errors[code]
+        const config = has ? (errors as any)[code] : undefined
 
-        return new ORPCError(code, {
-          defined: Boolean(config),
+        return new ORPCError(code as any, {
+          defined: has,
           status: config?.status,
           message: options.message ?? config?.message,
           data: options.data,
           cause: options.cause,
         })
       }
 
-      return item
+      __cache.set(code, item)
+      return item
     },
   })
 
   return proxy as any
 }
+
+// module-local cache holder (per map instance via closure); keep it typed but outside Proxy callback
+let __cacheMap: Map<string, any> | undefined
🧹 Nitpick comments (16)
packages/server/src/procedure-client.test.ts (2)

10-13: Mock should return a Promise by default to match the real signature.

Prevents accidental sync behavior if a test path uses the default mock.

-vi.mock('@orpc/contract', async origin => ({
-  ...await origin(),
-  validateORPCError: vi.fn((map, error) => error),
-}))
+vi.mock('@orpc/contract', async importOriginal => {
+  const actual = await importOriginal()
+  return {
+    ...actual,
+    validateORPCError: vi.fn(async (_map, error) => error),
+  }
+})

67-69: Avoid async describe callback.

describe blocks shouldn’t be async; it’s unnecessary here and can confuse test runners.

-describe.each(procedureCases)('createProcedureClient - case %s', async (_, procedure) => {
+describe.each(procedureCases)('createProcedureClient - case %s', (_, procedure) => {
apps/content/docs/plugins/response-validation.md (1)

38-49: Clarify “transforms” vs “coercion” to avoid confusion with the OpenAPI guide

State explicitly that coercion (e.g., z.coerce.*) is supported, while type-changing transforms are not.

 ## Limitations
 
-Schemas that transform data into different types than the expected schema types are not supported.
+Schemas that transform data into different types than the expected schema types are not supported.
+
+Note: Coercion that preserves the target type in your schema (for example, `z.coerce.date()`, `z.coerce.bigint()`) is supported and recommended.
packages/contract/src/error.test.ts (2)

73-78: Fix test title: it describes the precondition incorrectly

Input uses defined=false; the test asserts the validator returns defined=true.

-it('return new error when defined=true and data schema is undefined with match error', async () => {
+it('returns a new error with defined=true when defined=false and data schema is undefined (match)', async () => {

80-85: Optional: also cover defined=true + valid data path

Add a case where defined=true and data validates to ensure we still return a new error with validated data.

it('returns new error with defined=true and validated data when defined=true (match)', async () => {
  const e = new ORPCError('BAD_GATEWAY', { defined: true, data: { value: '123' } })
  const v = await validateORPCError(errors, e)
  expect(v).not.toBe(e)
  expect({ ...v }).toEqual({ ...e, defined: true, data: { value: 123 } })
})
apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md (2)

20-30: Provide imports for oc/implement in the snippet (or note prior context)

Without prior context, oc and implement are undefined in this standalone example.

-```ts
+```ts
+import { oc, implement } from '@orpc/contract'
 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,
 }))

50-52: Grammar: duplicate “first”

Remove the extra “first”.

- 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.
+ To support more types than those in [OpenAPI Handler](/docs/openapi/openapi-handler#supported-data-types), you must extend the [OpenAPI JSON Serializer](/docs/openapi/advanced/openapi-json-serializer) first.
packages/server/src/error.test.ts (1)

2-2: Decouple test from contract test fixtures

Import a local schema instead of reaching into another package’s test utilities.

-import { outputSchema } from '../../contract/tests/shared'
+import z from 'zod'
 describe('createORPCErrorConstructorMap', () => {
-  const errors = {
+  const outputSchema = z.object({ output: z.number() })
+  const errors = {
packages/contract/src/error.ts (4)

58-59: Tighten the function typing (input and return codes).

Use ORPCErrorCode to avoid any erosion and reflect actual code domain.

-export async function validateORPCError(map: ErrorMap, error: ORPCError<any, any>): Promise<ORPCError<string, unknown>> {
+export async function validateORPCError(
+  map: ErrorMap,
+  error: ORPCError<ORPCErrorCode, unknown>,
+): Promise<ORPCError<ORPCErrorCode, unknown>> {

62-66: Compute expected status once for clarity and future reuse.

Minor readability win; also makes it obvious what’s being compared.

-  if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) {
+  const expectedStatus = config ? fallbackORPCErrorStatus(code, config.status) : undefined
+  if (!config || expectedStatus !== status) {

68-72: Optionally prefer mapped message when upgrading to defined.

When we flip defined to true (no data schema), prefer config.message if the incoming error lacks one.

-  if (!config.data) {
+  if (!config.data) {
     return defined
-      ? error
-      : new ORPCError(code, { defined: true, status, message, data, cause })
+      ? error
+      : new ORPCError(code, {
+          defined: true,
+          status,
+          message: message ?? config.message,
+          data,
+          cause,
+        })
   }

74-80: Guard against validators that may throw.

Most validators return issues, but a defensive try/catch avoids turning validation failures into unexpected 500s.

-  const validated = await config.data['~standard'].validate(error.data)
+  let validated
+  try {
+    validated = await config.data['~standard'].validate(error.data)
+  }
+  catch {
+    return defined
+      ? new ORPCError(code, { defined: false, status, message, data, cause })
+      : error
+  }
packages/contract/src/plugins/response-validation.ts (2)

31-33: Message is clear; consider adding path context to aid debugging.

Including the full dot-path is great. Optionally add contract/router id if available.


35-54: Skip validation for streaming outputs (if any).

If a procedure can return an AsyncIterator with an output schema, validating the iterator object would be incorrect. Consider bypassing when Symbol.asyncIterator in output.

-        const output = await next()
+        const output = await next()
         const outputSchema = procedure['~orpc'].outputSchema

         if (!outputSchema) {
           return output
         }
+        if (output && typeof output === 'object' && Symbol.asyncIterator in (output as any)) {
+          return output
+        }
packages/contract/src/plugins/response-validation.test.ts (2)

66-66: Remove unnecessary async on describe.

describe should not be async; tests remain async at the it level.

-  describe('validate output', async () => {
+  describe('validate output', () => {

52-55: Stub client.call explicitly to reduce coupling.

Tests rely solely on codec.decode, but stubbing client.call to resolve a sentinel avoids accidental breakage if decode later depends on the response value.

-  const client = {
-    call: vi.fn(),
-  }
+  const client = {
+    call: vi.fn().mockResolvedValue({ ok: true }),
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f437dcb and abf0eac.

📒 Files selected for processing (16)
  • apps/content/.vitepress/config.ts (2 hunks)
  • apps/content/docs/openapi/advanced/expanding-type-support-for-openapi-link.md (1 hunks)
  • apps/content/docs/openapi/client/openapi-link.md (1 hunks)
  • apps/content/docs/plugins/client-retry.md (1 hunks)
  • apps/content/docs/plugins/response-validation.md (1 hunks)
  • packages/contract/package.json (1 hunks)
  • packages/contract/src/error.test.ts (2 hunks)
  • packages/contract/src/error.ts (2 hunks)
  • packages/contract/src/plugins/index.ts (1 hunks)
  • packages/contract/src/plugins/response-validation.test.ts (1 hunks)
  • packages/contract/src/plugins/response-validation.ts (1 hunks)
  • packages/server/src/error.test.ts (1 hunks)
  • packages/server/src/error.ts (1 hunks)
  • packages/server/src/index.ts (1 hunks)
  • packages/server/src/procedure-client.test.ts (1 hunks)
  • packages/server/src/procedure-client.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
packages/contract/src/plugins/response-validation.test.ts (2)
packages/client/src/adapters/standard/link.ts (1)
  • StandardLink (24-109)
packages/contract/src/plugins/response-validation.ts (1)
  • ResponseValidationPlugin (18-64)
packages/contract/src/plugins/response-validation.ts (4)
packages/client/src/adapters/standard/plugin.ts (1)
  • StandardLinkPlugin (4-7)
packages/contract/src/router.ts (1)
  • AnyContractRouter (17-17)
packages/client/src/adapters/standard/link.ts (1)
  • StandardLinkOptions (18-22)
packages/contract/src/procedure.ts (1)
  • isContractProcedure (51-66)
packages/contract/src/error.ts (3)
packages/server/src/index.ts (3)
  • validateORPCError (26-26)
  • ErrorMap (31-31)
  • ORPCError (24-24)
packages/contract/src/index.ts (1)
  • ORPCError (20-20)
packages/client/src/error.ts (1)
  • fallbackORPCErrorStatus (88-90)
packages/contract/src/error.test.ts (1)
packages/contract/src/error.ts (2)
  • ErrorMap (38-40)
  • validateORPCError (58-83)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: lint
  • GitHub Check: publish-commit
  • GitHub Check: test
🔇 Additional comments (19)
apps/content/docs/plugins/client-retry.md (1)

44-46: Good clarification on link compatibility.

This info block helps readers understand the plugin works across all oRPC links.

packages/server/src/index.ts (2)

26-26: Re-exporting validateORPCError from contract — LGTM.

Centralizing the API in @orpc/contract and surfacing it here is consistent and clearer.


24-26: No deep imports from @orpc/server/error detected; moving validateORPCError to @orpc/contract is safe.

apps/content/docs/openapi/client/openapi-link.md (1)

74-74: Clear guidance on JSON limitations and alternatives — good.

The warning succinctly points to the type-preserving path via the advanced guide.

packages/server/src/procedure-client.ts (1)

9-9: Import rewiring to contract — LGTM.

Using validateORPCError from @orpc/contract and keeping only createORPCErrorConstructorMap from ./error aligns with the new API surface.

Also applies to: 13-13

packages/server/src/error.ts (1)

4-4: Import change looks good.

No issues with importing ORPCError directly from @orpc/client.

packages/server/src/procedure-client.test.ts (2)

2-2: Good switch to contract-level import.

Aligns tests with the new public surface where validateORPCError lives.


15-18: Mock shape for ./error is fine.

Stubs only what’s needed and preserves other exports.

packages/contract/src/plugins/index.ts (1)

1-1: Export map includes ./plugins; consumers can import ResponseValidationPlugin from @orpc/contract/plugins.

apps/content/.vitepress/config.ts (2)

140-149: Docs sidebar: Response Validation entry looks good

Path and placement under Plugins are consistent with the new page.


279-282: OpenAPI Advanced nav: new page wired correctly

The link matches the new doc path and sits logically after “Customizing Error Response.”

apps/content/docs/plugins/response-validation.md (1)

16-32: Confirmed export of ResponseValidationPlugin via @orpc/contract/plugins
The packages/contract/src/plugins/index.ts barrel re-exports ResponseValidationPlugin, and package.json exposes "./plugins": "./src/plugins/index.ts", so the public import path is valid.

packages/server/src/error.test.ts (1)

1-12: LGTM: constructor map behavior and expectations are clear

Covers default message, overrides, unknown codes, and symbol access.

packages/contract/src/error.ts (2)

1-5: Good split between type-only and value imports.

Reduces runtime footprint and avoids potential circular deps.


82-83: LGTM: returns validated, typed data with defined: true.

This completes the “upgrade” path cleanly.

packages/contract/src/plugins/response-validation.ts (2)

23-29: Interceptor placement achieves intended execution order.

Pushing the interceptor ensures it runs closest to the core, so user interceptors observe validated output. Comment already hints this; no action needed.


56-58: Error remapping via validateORPCError is correct.

Consistent with contract error maps and keeps error surfaces predictable.

packages/contract/src/plugins/response-validation.test.ts (2)

58-64: Order assertion is effective.

Asserting interceptor sees transformed output proves the plugin sits inside user interceptors. Nice.


85-105: Ignore matcher availability concern. Vitest v3.2.4 ships with expect.toSatisfy built-in—no additional setup or custom matcher extension is required.

Likely an incorrect or invalid review comment.

Comment thread packages/contract/package.json
Comment thread packages/server/src/procedure-client.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/contract/src/plugins/response-validation.ts (2)

18-27: Make plugin initialization idempotent to prevent duplicate interceptors

If init is called more than once (re-linking, hot reloads), you’ll stack multiple validators.

Apply this diff:

 export class ResponseValidationPlugin<T extends ClientContext> implements StandardLinkPlugin<T> {
   constructor(
     private readonly contract: AnyContractRouter,
   ) {}
 
   order = 1_500_000 // make sure run before DurableEventIteratorLinkPlugin
+  private initialized = false
 
   init(options: StandardLinkOptions<T>): void {
-    options.interceptors ??= []
+    if (this.initialized) return
+    this.initialized = true
+    options.interceptors ??= []

56-61: Avoid throw await ... for clarity

Map first, then throw. Easier to debug and consistent with common style.

Apply this diff:

-        if (e instanceof ORPCError) {
-          throw await validateORPCError(procedure['~orpc'].errorMap, e)
-        }
+        if (e instanceof ORPCError) {
+          const mapped = await validateORPCError(procedure['~orpc'].errorMap, e)
+          throw mapped
+        }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f30b1ba and 31a0f2c.

📒 Files selected for processing (1)
  • packages/contract/src/plugins/response-validation.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/contract/src/plugins/response-validation.ts (4)
packages/client/src/adapters/standard/plugin.ts (1)
  • StandardLinkPlugin (4-7)
packages/contract/src/router.ts (1)
  • AnyContractRouter (17-17)
packages/client/src/adapters/standard/link.ts (1)
  • StandardLinkOptions (18-22)
packages/contract/src/procedure.ts (1)
  • isContractProcedure (51-66)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: publish-commit
  • GitHub Check: test
  • GitHub Check: lint
🔇 Additional comments (3)
packages/contract/src/plugins/response-validation.ts (3)

9-17: Solid plugin: clear scope and accurate docs

JSDoc and behavior align; mapping ORPCError via contract errorMap is a good touch.


23-23: Plugin ordering validated: ResponseValidationLinkPlugin (order = 1 500 000) correctly precedes DurableEventIteratorLinkPlugin (order = 2 100 000) when plugins are sorted ascending by order.


25-33: Using interceptors here is correct. The first‐stage interceptors wrap the entire request pipeline—including the decode_response step—so they always receive the final, decoded server output (both unary and streaming). No change needed.

Comment thread packages/contract/src/plugins/response-validation.ts
@dinwwwh dinwwwh merged commit 5a170fe into main Sep 1, 2025
9 of 10 checks passed
@sebastienbarre
Copy link
Copy Markdown

I'm curious to know as to why this only works for coerced schemas?
Why not handled dates in general, not just coerced dates?
Thank you

@dinwwwh
Copy link
Copy Markdown
Member Author

dinwwwh commented Nov 15, 2025

I'm curious to know as to why this only works for coerced schemas? Why not handled dates in general, not just coerced dates? Thank you

@sebastienbarre This isn't part of oRPC. If you're using Zod, please ask in the Zod community - oRPC doesn’t handle this.

@sebastienbarre
Copy link
Copy Markdown

sebastienbarre commented Nov 15, 2025

@unnoq Thanks for the clarification. I'm using Zod yes. Big fan of oRPC so far, but it is unfortunate that dates are a bit difficult to handle when working in OpenAPI mode (using the official documentation).

Considering this try-contract.ts where both my input and output are a Date:

import { oc } from '@orpc/contract';
import * as z from 'zod';

export const contract = {
  query: oc.input(z.date()).output(z.date()),
};

Then the input and output handler signature vary in all 3 scenarios in my try-client.ts:

  1. In pure RPC, as shown in "Getting Started" (https://orpc.unnoq.com/docs/getting-started#create-client)
const linkRpc = new RPCLink({ url });
const clientRpc: ContractRouterClient<typeof contract> = createORPCClient(linkRpc);
console.log(typeof (await clientRpc.query(new Date())));
  • query() signature with z.date(): (input: Date, output: Date)
  • query() signature with z.coerce.date(): (input: unknown, output: Date)

This works: my handler implementation does receive a Date as input, I can send back a Date as output, and client side I'm getting a Date.

  1. In OpenAPI mode (ttps://orpc.unnoq.com/docs/openapi/client/openapi-link)
const linkOpenApi = new OpenAPILink(contract, { url });
const clientOpenApi: JsonifiedClient<ContractRouterClient<typeof contract>> =
  createORPCClient(linkOpenApi);
console.log(typeof (await clientOpenApi.query(new Date())));
  • query() signature with z.date(): (input: Date, output: string)
  • query() signature with z.coerce.date(): (input: unknown, output: string)

This is problematic because it requires manual conversion of string back to Date for every call to the API. My handler implementation does receive a Date as input, I can send back a Date as output (I'm expected to per the type signature), but the client receives a string for some reasons, which I then have to convert back to a Date.

  1. In OpenAPI mode with ResponseValidationPlugin (https://orpc.unnoq.com/docs/openapi/advanced/expanding-type-support-for-openapi-link)
const linkOpenApiRvp = new OpenAPILink(contract, {
  url,
  plugins: [new ResponseValidationPlugin(contract)],
});
const clientOpenApiRvp: ContractRouterClient<typeof contract> = createORPCClient(linkOpenApiRvp);
console.log(typeof (await clientOpenApiRvp.query(new Date())));
  • query() signature with z.date(): (input: Date, output: Date)
  • query() signature with z.coerce.date(): (input: unknown, output: Date)

While the signature looks good when using z.date(), this is still not working, I'm getting "'Invalid input: expected date, received string'". Something is converting the input to string in the process, so the handler barks. I have no clue why. Removing the ZodToJsonSchemaConverter plugin doesn't help.

Finally, this leads me to using z.coerce.date(), per the ORPC documentation about ResponseValidationPlugin. I assume a string is still received, but it is turned into a Date automatically during coercion, and my handler is happy. Nevertheless, this is not ideal, as my input is now "unknown", and I'd rather be strict about types.

For reference, ts-server.ts is, for 2) and 3):

import { createServer } from 'node:http';
import { experimental_SmartCoercionPlugin as SmartCoercionPlugin } from '@orpc/json-schema';
import { OpenAPIHandler } from '@orpc/openapi/node';
import { implement } from '@orpc/server';
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
import { contract } from './try-contract.ts';

const os = implement(contract);
const appRouter = {
  query: os.query.handler(async ({ input }) => input),
};

const schemaConverters = [new ZodToJsonSchemaConverter()];

const handler = new OpenAPIHandler(appRouter, {
  plugins: [new SmartCoercionPlugin({ schemaConverters })],
});

const server = createServer(async (req, res) => await handler.handle(req, res));

server.listen(3000, '127.0.0.1');

@dinwwwh
Copy link
Copy Markdown
Member Author

dinwwwh commented Nov 16, 2025

@sebastienbarre try this:

const betterDateSchema = z.coerce.date<Date>()

Again all this is zod knowledge not related to oRPC

@sebastienbarre
Copy link
Copy Markdown

sebastienbarre commented Nov 16, 2025

We are talking passed each other here.

My contract is the same in all 3 situations I described. The contract is the only place where I'm using Zod.
What changes however between 1), 2), and 3), are the oRPC client/server components.

In situation 1) I'm using RPCLink, and ContractRouterClient<typeof contract> = createORPCClient(linkRpc);,

In situation 2), I'm using OpenAPILink, and JsonifiedClient<ContractRouterClient<typeof contract>> and SmartCoercionPlugin server-side.

All of these are from oRPC and essentially copy/pasted from the documentation. My contract hasn't changed at all, it's still using z.date(). The contract is the same, my handler code is the same, where both input and output are shown as Date by TypeScript.

const appRouter = {
  query: os.query.handler(async ({ input }) => input), // all Date
};

And yet in situation 1) my handler signature looks like this client-side:

image

In situation 2) it now looks like this (the output Date is now a string in the ClientPromiseResult):

image

No amount of coercion (z.coerce.date() or z.coerce.date()) changes that output type here, I tried, and I'm always getting a string.

So my question is simply: is this the expected behavior?

My (possibly incorrect) assumption was that I could use oRPC in RPC mode, write my handlers and client, and later switch to OpenAPILink as a drop-in replacement (and change the server code accordingly). I expected everything to keep working the same way, especially with SmartCoercionPlugin at play. Instead, all endpoints that previously returned actual Date objects now return strings. That’s fine if it’s by design, but it wasn’t obvious to me from the documentation.

@dinwwwh
Copy link
Copy Markdown
Member Author

dinwwwh commented Nov 16, 2025

@sebastienbarre please strictly follow this guide: https://orpc.unnoq.com/docs/openapi/advanced/expanding-type-support-for-openapi-link to remove JsonifiedClient when using OpenAPILink

Please create github issue/discussion for further discussion.

@sebastienbarre
Copy link
Copy Markdown

sebastienbarre commented Nov 16, 2025

OK thanks.

Alright just in case somebody stumbled upon this, I'm going to sum up what I think is going on when using oRPC in OpenAPI mode, and leave it at that.

  • Types like Date are transmitted as strings between the client and the server.
  • On the server, you can use the SmartCoercionPlugin to automatically convert input data back to native types before it reaches your handlers. In other words, if your schema uses z.date(), your handler can safely expect a Date even though it was transmitted as a string. There is no need for .coerce here.
  • This behavior does not apply in the opposite direction for output data. If your handler returns a Date (as it should, given a z.date() schema), it is still sent back to the client as a string.
  • On the client, you can use ResponseValidationPlugin to coerce the date-as-string received from the server back into a Date, but only if the Zod schema actually uses .coerce. That’s because the plugin essentially calls Zod's schema.validate() and returns the result. I’m not entirely sure why the same logic as SmartCoercionPlugin can’t be reused by ResponseValidationPlugin (via JsonSchemaCoercer maybe, like this but in reverse?), yet that’s how it currently works.
  • The implication is that you effectively have to maintain two sets of schemas:
    • One for input, without .coerce, if you want to be strict about what you accept everywhere else in your code (e.g., constructors).
    • One for output, with .coerce, for oRPC usage specifically. Zod does not provide a built-in way to surgically apply coercion to only certain fields in a schema, and writing a generic coerceZodDates(schema): schema helper is non-trivial (to me).
  • This does not apply when using oRPC in its pure RPC mode (not OpenAPI), where z.date() can be used for input/output, and everything works very nicely, no need for plugins, etc. That's my recommendation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to support native types (Date, ...) in OpenAPILink?

2 participants