-
-
Notifications
You must be signed in to change notification settings - Fork 144
feat(contract): Request Validation Plugin #1000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+247
−0
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| --- | ||
| title: Request Validation Plugin | ||
| description: A plugin that blocks invalid requests before they reach your server. Especially useful for applications that rely heavily on server-side validation. | ||
| --- | ||
|
|
||
| # Request Validation Plugin | ||
|
|
||
| The **Request Validation Plugin** ensures that only valid requests are sent to your server. This is especially valuable for applications that depend on server-side validation. | ||
|
|
||
| ::: 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 { RequestValidationPlugin } from '@orpc/contract/plugins' | ||
|
|
||
| const link = new RPCLink({ | ||
| url: 'http://localhost:3000/rpc', | ||
| plugins: [ | ||
| new RequestValidationPlugin(contract), | ||
| ], | ||
| }) | ||
|
|
||
| const client: ContractRouterClient<typeof contract> = 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. | ||
| ::: | ||
|
|
||
| ## Form Validation | ||
|
|
||
| You can simplify your frontend by removing heavy form validation libraries and relying on oRPC's validation errors instead, since input validation runs directly in the browser and is highly performant. | ||
|
|
||
| ```tsx | ||
| import { getIssueMessage, parseFormData } from '@orpc/openapi-client/helpers' | ||
|
|
||
| export function ContactForm() { | ||
| const [error, setError] = useState() | ||
|
|
||
| const handleSubmit = async (form: FormData) => { | ||
| try { | ||
| const output = await client.someProcedure(parseFormData(form)) | ||
| console.log(output) | ||
| } | ||
| catch (error) { | ||
| setError(error) | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <form action={handleSubmit}> | ||
| <input name="user[name]" type="text" /> | ||
| <span>{getIssueMessage(error, 'user[name]')}</span> | ||
|
|
||
| <input name="user[emails][]" type="email" /> | ||
| <span>{getIssueMessage(error, 'user[emails][]')}</span> | ||
|
|
||
| <button type="submit">Submit</button> | ||
| </form> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| ::: info | ||
| This example uses [Form Data Helpers](/docs/helpers/form-data). | ||
| ::: | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from './request-validation' | ||
| export * from './response-validation' |
107 changes: 107 additions & 0 deletions
107
packages/contract/src/plugins/request-validation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { ORPCError } from '@orpc/client' | ||
| import { StandardLink } from '@orpc/client/standard' | ||
| import * as z from 'zod' | ||
| import { ValidationError } from '../error' | ||
| import { ContractProcedure } from '../procedure' | ||
| import { RequestValidationPlugin, RequestValidationPluginError } from './request-validation' | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| describe('requestValidationPlugin', () => { | ||
| const schema = z.object({ | ||
| value: z.number().transform(v => v.toString()), | ||
| }) | ||
|
|
||
| const procedure = new ContractProcedure({ | ||
| inputSchema: schema, | ||
| errorMap: { | ||
| TEST: { | ||
| data: schema, | ||
| }, | ||
| }, | ||
| meta: {}, | ||
| route: {}, | ||
| }) | ||
|
|
||
| const withoutInputSchemaProcedure = new ContractProcedure({ | ||
| errorMap: {}, | ||
| meta: {}, | ||
| route: {}, | ||
| }) | ||
|
|
||
| const contract = { | ||
| procedure, | ||
| nested: { | ||
| procedure, | ||
| }, | ||
| withoutInputSchema: withoutInputSchemaProcedure, | ||
| } | ||
|
|
||
| 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 RequestValidationPlugin(contract), | ||
| ], | ||
| // RequestValidationPlugin should execute before user defined interceptors | ||
| interceptors: [interceptor], | ||
| }) | ||
|
|
||
| describe('validate input', async () => { | ||
| it('procedure with input schema', async () => { | ||
| codec.decode.mockResolvedValueOnce('__output__') | ||
|
|
||
| const output = await link.call(['procedure'], { value: 123 }, { context: {} }) | ||
|
|
||
| expect(output).toEqual('__output__') | ||
| expect(client.call.mock.calls[0]?.[3]).toEqual( | ||
| { value: 123 }, | ||
| ) | ||
| }) | ||
|
|
||
| it('procedure without input schema', async () => { | ||
| codec.decode.mockResolvedValueOnce('__output__') | ||
|
|
||
| const output = await link.call(['withoutInputSchema'], 'anything', { context: {} }) | ||
|
|
||
| expect(output).toEqual('__output__') | ||
| expect(client.call.mock.calls[0]?.[3]).toEqual('anything') | ||
| }) | ||
|
|
||
| it('throw if input does not match the expected schema', async () => { | ||
| await expect(link.call(['nested', 'procedure'], { value: 'not a number' }, { context: {} })).rejects.toThrow( | ||
| new ORPCError('BAD_REQUEST', { | ||
| message: 'Input validation failed', | ||
| data: { | ||
| issues: expect.any(Object), | ||
| }, | ||
| cause: new ValidationError({ | ||
| message: 'Input validation failed', | ||
| issues: expect.any(Array), | ||
| data: { value: 'not a number' }, | ||
| }), | ||
| }), | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| it('throw if not find matching contract', async () => { | ||
| await expect(link.call(['not', 'found'], {}, { context: {} })).rejects.toThrow( | ||
| new RequestValidationPluginError('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( | ||
| new RequestValidationPluginError('No valid procedure found at path "not.found", this may happen when the contract router is not properly configured.'), | ||
| ) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { ValidationError } from '../error' | ||
| import { isContractProcedure } from '../procedure' | ||
|
|
||
| export class RequestValidationPluginError extends Error {} | ||
|
|
||
| /** | ||
| * A link plugin that validates client requests against your contract schema, | ||
| * ensuring that data sent to your server matches the expected types defined in your contract. | ||
| * | ||
| * @throws {ORPCError} with code `BAD_REQUEST` (same as server side) if input doesn't match the expected schema | ||
| * @see {@link https://orpc.unnoq.com/docs/plugins/request-validation Request Validation Plugin Docs} | ||
| */ | ||
| export class RequestValidationPlugin<T extends ClientContext> implements StandardLinkPlugin<T> { | ||
| constructor( | ||
| private readonly contract: AnyContractRouter, | ||
| ) {} | ||
|
|
||
| init(options: StandardLinkOptions<T>): void { | ||
| options.interceptors ??= [] | ||
|
|
||
| options.interceptors.push(async ({ next, path, input }) => { | ||
|
dinwwwh marked this conversation as resolved.
|
||
| const procedure = get(this.contract, path) | ||
|
|
||
| if (!isContractProcedure(procedure)) { | ||
| throw new RequestValidationPluginError(`No valid procedure found at path "${path.join('.')}", this may happen when the contract router is not properly configured.`) | ||
| } | ||
|
|
||
| const inputSchema = procedure['~orpc'].inputSchema | ||
|
|
||
| if (inputSchema) { | ||
| const result = await inputSchema['~standard'].validate(input) | ||
|
|
||
| if (result.issues) { | ||
| /** | ||
| * This error should be same as server side when input validation fails. | ||
| */ | ||
| throw new ORPCError('BAD_REQUEST', { | ||
| message: 'Input validation failed', | ||
| data: { | ||
| issues: result.issues, | ||
| }, | ||
| cause: new ValidationError({ | ||
| message: 'Input validation failed', | ||
| issues: result.issues, | ||
| data: input, | ||
| }), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * we should not use validated input here, | ||
| * because validated input maybe is transformed by schema | ||
| * leading input no longer matching expected schema | ||
| */ | ||
| return await next() | ||
| }) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.