From 6bf89dbf6e157389cd88ca9309485c70f4a0dff5 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 10:56:44 +0200 Subject: [PATCH 01/14] feat(rpc): support QUERY transport in RPC links --- apps/content/docs/plugins/cors.mdx | 4 ++- apps/content/docs/rpc/handler.mdx | 2 +- apps/content/docs/rpc/link.mdx | 21 +++++++++++++ apps/content/docs/rpc/protocol.mdx | 2 +- .../src/adapters/fetch/rpc-link.test-d.ts | 15 +++++++++ .../src/adapters/fetch/rpc-link.test.ts | 29 +++++++++++++++++ .../adapters/standard/rpc-link-codec.test.ts | 31 +++++++++++++++++++ .../src/adapters/standard/rpc-link-codec.ts | 4 +-- packages/server/src/plugins/cors.test.ts | 19 ++++++++++-- packages/server/src/plugins/cors.ts | 4 +-- 10 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 packages/client/src/adapters/fetch/rpc-link.test-d.ts diff --git a/apps/content/docs/plugins/cors.mdx b/apps/content/docs/plugins/cors.mdx index c6c3d4bbf..5c0a3b68d 100644 --- a/apps/content/docs/plugins/cors.mdx +++ b/apps/content/docs/plugins/cors.mdx @@ -17,7 +17,7 @@ const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: (origin, options) => origin, - allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], + allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'], // ... }), ], @@ -26,6 +26,8 @@ const handler = new RPCHandler(router, { :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. + +The default `allowMethods` list includes [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html). Browser QUERY requests are not CORS-safelisted, so they require a preflight response that advertises the method. Providing `allowMethods` replaces the default list. ::: :::warning diff --git a/apps/content/docs/rpc/handler.mdx b/apps/content/docs/rpc/handler.mdx index 32d309824..d743194b2 100644 --- a/apps/content/docs/rpc/handler.mdx +++ b/apps/content/docs/rpc/handler.mdx @@ -70,7 +70,7 @@ By default, `RPCHandler` only responds to `POST`, `PUT`, `PATCH`, and `DELETE` r This is a security default: cross-site, browsers can only send these methods via a [CORS preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) or an HTML form, never from a plain link. Safe methods like `GET` or `HEAD` are excluded because invoking a procedure can modify data. -Use `allowMethods` to replace the allowlist: tighten it to `POST` only, or also accept [`QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/), which reads input from the request body and stays preflight-protected: +Use `allowMethods` to replace the allowlist: tighten it to `POST` only, or explicitly accept [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html), which is a safe, idempotent method that reads input from the request body and stays preflight-protected. Only enable QUERY for procedures that satisfy those semantics. It remains excluded from the default allowlist, and support depends on every HTTP runtime, framework, proxy, and gateway in the request path accepting and forwarding custom methods: ```ts const handler = new RPCHandler(router, { diff --git a/apps/content/docs/rpc/link.mdx b/apps/content/docs/rpc/link.mdx index bf46f5f41..43976fac9 100644 --- a/apps/content/docs/rpc/link.mdx +++ b/apps/content/docs/rpc/link.mdx @@ -263,8 +263,29 @@ const link = new RPCLink({ `RPCLink` sends requests with `POST` by default. Use `method` to choose the method per call. +To send safe, idempotent requests with body-encoded input, choose the [`QUERY` method defined by RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html): + +```ts +const link = new RPCLink({ + url: '/rpc', + method: 'QUERY', +}) +``` + +When using `GET`, input is encoded in the URL. If the URL exceeds `maxUrlLength` or cannot carry the serialized input, `RPCLink` sends the input in a request body using `fallbackMethod`. The fallback defaults to `POST`; choose `QUERY` explicitly when the procedure is safe and idempotent: + +```ts +const link = new RPCLink({ + url: '/rpc', + method: 'GET', + fallbackMethod: 'QUERY', +}) +``` + :::warning By default, [RPC handlers](/docs/rpc/handler#supported-http-methods) only accept `POST`, `PUT`, `PATCH`, and `DELETE` requests. Before sending `GET` or `QUERY` requests, allow them in the handler first, and understand [the risks of enabling `GET`](/docs/rpc/handler#enabling-the-get-method). + +Browser `QUERY` requests require a CORS preflight. QUERY also works only when every HTTP runtime, framework, proxy, and gateway in the request path accepts and forwards custom methods. ::: ```ts diff --git a/apps/content/docs/rpc/protocol.mdx b/apps/content/docs/rpc/protocol.mdx index da9cd294a..7d87089a2 100644 --- a/apps/content/docs/rpc/protocol.mdx +++ b/apps/content/docs/rpc/protocol.mdx @@ -42,7 +42,7 @@ const router = { ## Sending Input -Requests can use the `POST`, `PUT`, `PATCH`, or `DELETE` method, or other methods like `GET` and `QUERY` when the server [allows them](/docs/rpc/handler#supported-http-methods). Send input in the query string (`GET`) or request body (other methods). +Requests can use the `POST`, `PUT`, `PATCH`, or `DELETE` method, or other methods like `GET` and [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) when the server [allows them](/docs/rpc/handler#supported-http-methods). Send input in the query string for `GET` and in the request body for all other methods. QUERY requires explicit server opt-in and an HTTP runtime that accepts custom methods. :::info Request payloads depend on the serializer and are not plain JSON. Learn more in [RPC Serializer Format](/docs/rpc/serializer#serialization-format). diff --git a/packages/client/src/adapters/fetch/rpc-link.test-d.ts b/packages/client/src/adapters/fetch/rpc-link.test-d.ts new file mode 100644 index 000000000..2feffa252 --- /dev/null +++ b/packages/client/src/adapters/fetch/rpc-link.test-d.ts @@ -0,0 +1,15 @@ +import { RPCLink } from './rpc-link' + +describe('RPCLink', () => { + it('accepts QUERY only for body-bearing request methods', () => { + void new RPCLink({ method: 'QUERY' }) + void new RPCLink({ method: 'GET', fallbackMethod: 'QUERY' }) + + // @ts-expect-error - HEAD is not a supported direct RPC request method + void new RPCLink({ method: 'HEAD' }) + // @ts-expect-error - OPTIONS is not a supported direct RPC request method + void new RPCLink({ method: 'OPTIONS' }) + // @ts-expect-error - GET cannot be used as a body-bearing fallback + void new RPCLink({ fallbackMethod: 'GET' }) + }) +}) diff --git a/packages/client/src/adapters/fetch/rpc-link.test.ts b/packages/client/src/adapters/fetch/rpc-link.test.ts index e69f971c9..51e1e6622 100644 --- a/packages/client/src/adapters/fetch/rpc-link.test.ts +++ b/packages/client/src/adapters/fetch/rpc-link.test.ts @@ -56,6 +56,35 @@ describe('rpcLink', () => { ) }) + it('sends QUERY requests with body-encoded input', async () => { + const fetch = vi.fn(async () => { + return new Response(JSON.stringify({ json: 'pong' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + }) + + const orpc = createORPCClient(new RPCLink({ + fetch, + method: 'QUERY', + origin: 'http://api.example.com', + })) as any + + await expect(orpc.ping('input')).resolves.toEqual('pong') + + expect(fetch).toHaveBeenCalledWith( + 'http://api.example.com/ping', + expect.objectContaining({ + body: JSON.stringify({ json: 'input' }), + method: 'QUERY', + }), + expect.objectContaining({ context: {} }), + ['ping'], + ) + }) + it('supports custom headers and query parameters in origin', async () => { const fetch = vi.fn(async () => { return new Response(JSON.stringify({ json: 'pong' }), { diff --git a/packages/client/src/adapters/standard/rpc-link-codec.test.ts b/packages/client/src/adapters/standard/rpc-link-codec.test.ts index 397433556..e628ef376 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.test.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.test.ts @@ -88,6 +88,37 @@ describe('rpcLinkCodec', () => { expect(request.url).toBe('/api/ping') }) + it('falls back to QUERY with a body when GET url exceeds maxUrlLength', async () => { + const codec = new RPCLinkCodec({ + url: '/api', + method: 'GET', + maxUrlLength: 10, + fallbackMethod: 'QUERY', + serializer, + }) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) + + expect(request.method).toBe('QUERY') + expect(request.body).toBe(serializeSpy.mock.results[0]!.value) + expect(request.url).toBe('/api/ping') + }) + + it('falls back to POST by default when GET url exceeds maxUrlLength', async () => { + const codec = new RPCLinkCodec({ + url: '/api', + method: 'GET', + maxUrlLength: 10, + serializer, + }) + + const request = await codec.encodeInput('input', ['ping'], { context: {} }) + + expect(request.method).toBe('POST') + expect(request.body).toBe(serializeSpy.mock.results[0]!.value) + expect(request.url).toBe('/api/ping') + }) + it.each([ ['FormData', () => { const f = new FormData() diff --git a/packages/client/src/adapters/standard/rpc-link-codec.ts b/packages/client/src/adapters/standard/rpc-link-codec.ts index 86440bdc9..e008436a8 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.ts @@ -32,7 +32,7 @@ export interface RPCLinkCodecOptions { * * @default 'POST' */ - method?: Value, [options: ClientOptions, path: string[], input: unknown]> + method?: Value, [options: ClientOptions, path: string[], input: unknown]> /** * The method to use when the payload cannot safely pass to the server with method return from method function. @@ -40,7 +40,7 @@ export interface RPCLinkCodecOptions { * * @default 'POST' */ - fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE' + fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY' /** * Inject headers to the request. diff --git a/packages/server/src/plugins/cors.test.ts b/packages/server/src/plugins/cors.test.ts index e4f290df8..9b2b3192a 100644 --- a/packages/server/src/plugins/cors.test.ts +++ b/packages/server/src/plugins/cors.test.ts @@ -28,7 +28,7 @@ describe('corsHandlerPlugin', () => { expect(response!.status).toBe(204) expect(response!.headers.get('access-control-allow-origin')).toBe('https://example.com') expect(response!.headers.get('vary')).toBe('Origin') - expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH') + expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH, QUERY') expect(response!.headers.get('access-control-max-age')).toBeNull() }) @@ -50,7 +50,7 @@ describe('corsHandlerPlugin', () => { })) expect(response!.headers.get('access-control-max-age')).toBe('600') - expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH') + expect(response!.headers.get('access-control-allow-methods')).toBe('GET, HEAD, PUT, POST, DELETE, PATCH, QUERY') expect(response!.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization') }) @@ -76,6 +76,21 @@ describe('corsHandlerPlugin', () => { expect(response!.headers.get('access-control-allow-headers')).toBeNull() }) + it('uses an explicitly configured method list instead of the default', async () => { + const handler = new RPCHandler(router, { + plugins: [new CORSHandlerPlugin({ allowMethods: ['POST'] })], + }) + + const { response } = await handler.handle(new Request('https://example.com', { + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + }, + })) + + expect(response!.headers.get('access-control-allow-methods')).toBe('POST') + }) + it('sets allowed origin only when custom origin function approves', async () => { const customOrigin = (origin: string) => origin === 'https://allowed.com' ? origin : null const customRouter = { diff --git a/packages/server/src/plugins/cors.ts b/packages/server/src/plugins/cors.ts index 98f1c8c15..6d7e1c67c 100644 --- a/packages/server/src/plugins/cors.ts +++ b/packages/server/src/plugins/cors.ts @@ -25,7 +25,7 @@ export interface CORSHandlerPluginOptions { /** * Configures the `Access-Control-Allow-Methods` header for preflight requests. * - * @default ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'] + * @default ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'] */ allowMethods?: readonly string[] @@ -80,7 +80,7 @@ export class CORSHandlerPlugin implements StandardHandlerPlug constructor(options: CORSHandlerPluginOptions = {}) { const defaults: CORSHandlerPluginOptions = { origin: origin => origin, - allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], + allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'], } this.options = { From 47942ccb712fabce8da3728e82d0bad50142b192 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 11:11:36 +0200 Subject: [PATCH 02/14] feat(openapi): support QUERY operations --- apps/content/docs/openapi/routing.mdx | 15 ++++++ apps/content/docs/openapi/specification.mdx | 24 +++++++++- .../src/adapters/fetch/openapi-link.test.ts | 36 +++++++++++++++ packages/openapi/src/meta.ts | 2 +- .../openapi/src/openapi-generator.test.ts | 46 +++++++++++++++++++ packages/openapi/src/openapi-generator.ts | 10 +++- packages/openapi/src/types.test-d.ts | 10 +++- packages/openapi/src/types.ts | 18 +++++++- 8 files changed, 155 insertions(+), 6 deletions(-) diff --git a/apps/content/docs/openapi/routing.mdx b/apps/content/docs/openapi/routing.mdx index 08eabead2..a2943a657 100644 --- a/apps/content/docs/openapi/routing.mdx +++ b/apps/content/docs/openapi/routing.mdx @@ -25,6 +25,21 @@ const router = { In this example, `list` is exposed as `GET /planets` because it overrides the default method and path. `create` keeps the default behavior, so it is exposed as `POST /planet/create`. +### QUERY Method + +Use the [`QUERY` method defined by RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html) for safe, idempotent operations that need body-encoded input: + +```ts +const searchPlanets = os + .meta(openapi({ method: 'QUERY', path: '/planets/search' })) + .input(z.object({ names: z.array(z.string()) })) + .handler(async ({ input }) => findPlanets(input.names)) +``` + +The OpenAPI client sends compact QUERY input in the request body, and the OpenAPI handler reads it from there. Documenting QUERY requires an [explicit OpenAPI 3.2 base document](/docs/openapi/specification#query-operations). + +QUERY works only when every HTTP runtime, framework, proxy, gateway, and client in the request path accepts and forwards the method. + ## Path Parameters To define a path parameter, use `{name}` in the `path` and add the same field as a required key in the input schema: diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 565c19123..5bc105255 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -1,6 +1,6 @@ --- title: "OpenAPI Specification" -description: "Learn how to configure openapi metadata and generate OpenAPI 3.1 documents from your oRPC contracts and routers with OpenAPIGenerator." +description: "Learn how to configure openapi metadata and generate OpenAPI documents from your oRPC contracts and routers with OpenAPIGenerator." --- ## Metadata @@ -131,7 +131,7 @@ In this example, the final `tags` is `undefined`, so no tags are applied to `exa ## OpenAPI Generator -`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document. +`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI document. It uses OpenAPI 3.1.2 by default. ```ts import { OpenAPIGenerator } from '@orpc/openapi' @@ -153,6 +153,26 @@ const spec = await generator.generate(router, { }) ``` +### QUERY Operations + +OpenAPI defines the `query` Path Item field starting in OpenAPI 3.2. When a router contains a [`QUERY` operation](/docs/openapi/routing#query-method), explicitly select OpenAPI 3.2 in the base document: + +```ts +const spec = await generator.generate(router, { + base: { + openapi: '3.2.0', + info: { + title: 'Planet API', + version: '1.0.0', + }, + }, +}) +``` + +Without `base.openapi: '3.2.0'`, generation fails rather than emitting the 3.2-only `query` field in the default OpenAPI 3.1.2 document. Routers without QUERY operations continue to generate OpenAPI 3.1.2 documents by default. + +oRPC's public document type includes only the OpenAPI 3.2 compatibility needed for QUERY Path Items; it does not claim complete OpenAPI 3.2 coverage. OpenAPI viewers, client generators, validators, HTTP runtimes, proxies, and gateways may not support OpenAPI 3.2 or the QUERY method yet. Verify every tool and network hop used by your API before adopting it. + ### Json Schema Converters `OpenAPIGenerator` relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides dedicated converters through the [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), and [ArkType](/docs/integrations/arktype) integrations: diff --git a/packages/openapi/src/adapters/fetch/openapi-link.test.ts b/packages/openapi/src/adapters/fetch/openapi-link.test.ts index fc4549f00..d708e5c08 100644 --- a/packages/openapi/src/adapters/fetch/openapi-link.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-link.test.ts @@ -14,6 +14,9 @@ describe('openapiLink', () => { .meta(openapi({ method: 'GET', path: '/ping/{pong}' })) .handler(({ input }) => input), post: os.handler(({ input }) => input), + query: os + .meta(openapi({ method: 'QUERY', path: '/query' })) + .handler(({ input }) => input), } const handler = new OpenAPIHandler(router) @@ -105,6 +108,39 @@ describe('openapiLink', () => { }) }) + it('calls a QUERY OpenAPI endpoint with body-encoded input', async () => { + const fetch = vi.fn(async (url: string, init: RequestInit) => { + const request = new Request(url, init) + + await expect(request.clone().json()).resolves.toEqual({ search: 'earth' }) + + const { matched, response } = await handler.handle(request, { + prefix: '/api', + }) + + if (!matched || !response) { + throw new Error('No procedure match') + } + + return response + }) + + const client = createORPCClient(new OpenAPILink(router, { + fetch, + origin: 'http://localhost:3000', + url: '/api', + })) as any + + await expect(client.query({ search: 'earth' })).resolves.toEqual({ search: 'earth' }) + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/query', + expect.objectContaining({ method: 'QUERY' }), + expect.objectContaining({ context: {} }), + ['query'], + ) + }) + it('calls a POST OpenAPI endpoint with multipart payloads', async () => { const client = createORPCClient(new OpenAPILink(router, { origin: 'http://localhost:3000', diff --git a/packages/openapi/src/meta.ts b/packages/openapi/src/meta.ts index f299e6d78..6a4f29299 100644 --- a/packages/openapi/src/meta.ts +++ b/packages/openapi/src/meta.ts @@ -11,7 +11,7 @@ export interface OpenAPIMeta { * * @default 'POST' */ - method?: 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | undefined + method?: 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'QUERY' | undefined /** * URL path for this procedure. Supports dynamic parameters via `{param}` syntax, diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 43f7fdb1f..1449562d2 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -18,6 +18,52 @@ describe('openAPIGenerator basic & options', () => { }) }) + it('generates QUERY operations in an explicit OpenAPI 3.2 document', async () => { + const doc = await generator.generate({ + search: oc + .meta(openapi({ method: 'QUERY', path: '/search' })) + .input(z.object({ term: z.string() })) + .output(z.object({ result: z.string() })), + }, { + base: { openapi: '3.2.0' }, + }) + + expect(doc.openapi).toBe('3.2.0') + expect(doc.paths?.['/search']?.query).toEqual(expect.objectContaining({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: expect.objectContaining({ + properties: { term: { type: 'string' } }, + required: ['term'], + }), + }, + }, + }, + responses: { + 200: expect.objectContaining({ + content: { + 'application/json': { + schema: expect.objectContaining({ + properties: { result: { type: 'string' } }, + required: ['result'], + }), + }, + }, + }), + }, + })) + }) + + it('rejects QUERY operations unless the base document uses OpenAPI 3.2', async () => { + await expect(generator.generate({ + search: oc.meta(openapi({ method: 'QUERY', path: '/search' })), + })).rejects.toThrow( + 'QUERY operations require OpenAPI 3.2. Set base.openapi to \'3.2.0\'.', + ) + }) + it('merges the provided base document and serialize the result', async () => { const serializer = { serialize: vi.fn(document => document), diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index e93488091..aa6c11fa4 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -78,7 +78,8 @@ export interface OpenAPIGeneratorGenerateOptions { } /** - * Generates an OpenAPI 3.1 document from a contract or a router. + * Generates an OpenAPI 3.1 document, or an OpenAPI 3.2 document for QUERY operations, + * from a contract or a router. * Relies on JSON schema converters to translate input, output, and error schemas into JSON Schemas. * * @see {@link https://orpc.dev/docs/openapi/specification#openapi-generator | OpenAPI Specification - OpenAPI Generator} @@ -121,6 +122,13 @@ export class OpenAPIGenerator { const meta = getOpenAPIMeta(contract) const method = (meta?.method ?? DEFAULT_OPENAPI_METHOD).toLowerCase() as Lowercase> + + if (method === 'query' && doc.openapi !== '3.2.0') { + throw new OpenAPIGeneratorError( + `QUERY operations require OpenAPI 3.2. Set base.openapi to '3.2.0'.`, + ) + } + const postPath = meta?.path ?? pathToHttpPath(path) const httpPath = meta?.prefix ? mergeHttpPath(meta.prefix, postPath) : postPath const dynamicPathParams = getDynamicPathParams(httpPath) diff --git a/packages/openapi/src/types.test-d.ts b/packages/openapi/src/types.test-d.ts index e3ce02d68..b07401a76 100644 --- a/packages/openapi/src/types.test-d.ts +++ b/packages/openapi/src/types.test-d.ts @@ -1,10 +1,18 @@ import type { Client, ORPCError } from '@orpc/client' import type { RouterContractClient } from '@orpc/contract' import type { AsyncIteratorClass } from '@orpc/shared' -import type { JsonifiedClient, JsonifiedValue } from './types' +import type { JsonifiedClient, JsonifiedValue, OpenAPIDocument, OpenAPIOperationObject } from './types' import { asyncIteratorObject, oc } from '@orpc/contract' import z from 'zod' +describe('OpenAPIDocument', () => { + it('narrowly supports OpenAPI 3.2 QUERY path items', () => { + expectTypeOf().toEqualTypeOf<'3.1.0' | '3.1.1' | '3.1.2' | '3.2.0'>() + + expectTypeOf['/search']['query']>().toEqualTypeOf() + }) +}) + describe('JsonifiedValue', () => { it('flat', () => { expectTypeOf>().toEqualTypeOf() diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 8cd9a9528..bbe1cfe5b 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -3,9 +3,25 @@ import type { OpenAPIV3_1 } from '@hey-api/spec-types' import type { AnyNestedClient, Client, ORPCError } from '@orpc/client' import type { AsyncIteratorClass } from '@orpc/shared' -export type OpenAPIDocument = OpenAPIV3_1.Document export type OpenAPIOperationObject = OpenAPIV3_1.OperationObject +export type OpenAPIPathItemObject = OpenAPIV3_1.PathItemObject & { + query?: OpenAPIOperationObject | undefined +} + +export type OpenAPIPathsObject = OpenAPIV3_1.PathsObject & { + [path: `/${string}`]: OpenAPIPathItemObject +} + +/** + * An OpenAPI 3.1 document with the OpenAPI 3.2 QUERY additions used by oRPC. + * This type does not claim support for the complete OpenAPI 3.2 specification. + */ +export type OpenAPIDocument = Omit & { + openapi: OpenAPIV3_1.Document['openapi'] | '3.2.0' + paths?: OpenAPIPathsObject | undefined +} + export type JsonifiedValue = T extends string ? T : T extends number ? T From fcfc177def5873699ac464d53be6a22b80a3ac21 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 11:26:41 +0200 Subject: [PATCH 03/14] feat(client): dedupe QUERY requests by default --- apps/content/docs/plugins/dedupe.mdx | 2 +- packages/client/src/plugins/dedupe.test.ts | 57 ++++++++++++++++++++++ packages/client/src/plugins/dedupe.ts | 4 +- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/content/docs/plugins/dedupe.mdx b/apps/content/docs/plugins/dedupe.mdx index f85d4d179..b2c727351 100644 --- a/apps/content/docs/plugins/dedupe.mdx +++ b/apps/content/docs/plugins/dedupe.mdx @@ -30,7 +30,7 @@ The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [O ## Filter -By default, the plugin deduplicates only `GET` requests. You can customize this behavior by providing a `filter` function. +By default, the plugin deduplicates `GET` and `QUERY` requests. You can customize this behavior by providing a `filter` function. ```ts const link = new RPCLink({ diff --git a/packages/client/src/plugins/dedupe.test.ts b/packages/client/src/plugins/dedupe.test.ts index b334e0886..f720099d8 100644 --- a/packages/client/src/plugins/dedupe.test.ts +++ b/packages/client/src/plugins/dedupe.test.ts @@ -108,6 +108,63 @@ describe('dedupeLinkPlugin', () => { })) }) + it('dedupes identical QUERY requests by default', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }), + link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }), + ]) + + expect(output1).toBe(output2) + expect(transport.send).toHaveBeenCalledTimes(1) + }) + + it('does not dedupe QUERY requests with different bodies', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }), + link.call(['QUERY', 'planet'], { value: 2 }, { context: {} }), + ]) + + expect(output1).not.toBe(output2) + expect(transport.send).toHaveBeenCalledTimes(2) + }) + + it('does not dedupe unsafe methods by default', async () => { + const codec = makeCodec() + const transport = makeTransport() + + const link = new StandardLink(codec, transport, { + plugins: [new DedupeLinkPlugin({ + groups: [{ condition: () => true, context: { group: true } }], + })], + }) + + const [output1, output2] = await Promise.all([ + link.call(['POST', 'planet'], { value: 1 }, { context: {} }), + link.call(['POST', 'planet'], { value: 1 }, { context: {} }), + ]) + + expect(output1).not.toBe(output2) + expect(transport.send).toHaveBeenCalledTimes(2) + }) + it('computes group context from all deduped matching options', async () => { const codec = makeCodec() const transport = makeTransport() diff --git a/packages/client/src/plugins/dedupe.ts b/packages/client/src/plugins/dedupe.ts index 1d45892dc..abf31228f 100644 --- a/packages/client/src/plugins/dedupe.ts +++ b/packages/client/src/plugins/dedupe.ts @@ -26,7 +26,7 @@ export interface DedupeLinkPluginOptions { /** * Filters requests to dedupe. * - * @default ({ request }) => request.method === 'GET' + * @default ({ request }) => request.method === 'GET' || request.method === 'QUERY' */ filter?: Value]> } @@ -48,7 +48,7 @@ export class DedupeLinkPlugin implements StandardLinkPl constructor(options: NoInfer>) { this.groups = options.groups - this.filter = options.filter ?? (({ request }) => request.method === 'GET') + this.filter = options.filter ?? (({ request }) => request.method === 'GET' || request.method === 'QUERY') } init(options: StandardLinkOptions): StandardLinkOptions { From 41cefbf2e88157536cc2a0a066ce0c71075a1d79 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 11:59:21 +0200 Subject: [PATCH 04/14] feat(client,server): safely batch QUERY requests --- apps/content/docs/plugins/batch.mdx | 12 ++++++++++ packages/client/src/plugins/batch.test.ts | 20 +++++++++------- packages/client/src/plugins/batch.ts | 8 ++++--- packages/server/src/plugins/batch.test.ts | 23 +++++++++++++++++-- packages/server/src/plugins/batch.ts | 23 +++++++++---------- .../__shared__/client-server.hono-fetch.ts | 5 ++-- .../__shared__/client-server.node-http.ts | 3 +++ tests/batch/__shared__/client-server.ts | 1 + tests/batch/batch-plugin.test.ts | 18 +++++++++++++++ 9 files changed, 86 insertions(+), 27 deletions(-) diff --git a/apps/content/docs/plugins/batch.mdx b/apps/content/docs/plugins/batch.mdx index fcce50d59..07b37f480 100644 --- a/apps/content/docs/plugins/batch.mdx +++ b/apps/content/docs/plugins/batch.mdx @@ -56,6 +56,18 @@ const cors = new CORSHandlerPlugin({ ::: +## Request Methods + +Within each configured group, the client keeps request methods in separate batches: + +- `GET` calls use an outer GET request with the batch data encoded in the URL. +- `QUERY` calls use a body-bearing outer QUERY request. +- Unsafe calls use an outer POST request. + +GET, QUERY, and unsafe calls are never mixed in one batch. The server also requires every request message inside a safe outer GET or QUERY batch to use that same method, and rejects the entire malformed batch before any procedure executes. This preserves method-based security, caching, and policy boundaries. + +QUERY remains opt-in on the RPC handler. Add it to the handler's [`allowMethods`](/docs/rpc/handler#supported-http-methods) and only use it for safe, idempotent procedures. QUERY batching works only when every HTTP runtime, framework, proxy, and gateway in the request path accepts and forwards custom methods. + ## Response Modes By default, the plugin uses `streaming` mode. Responses are sent as soon as they are ready, so one slow request does not block the rest of the batch. diff --git a/packages/client/src/plugins/batch.test.ts b/packages/client/src/plugins/batch.test.ts index 89e53c1c1..98f8d3cc5 100644 --- a/packages/client/src/plugins/batch.test.ts +++ b/packages/client/src/plugins/batch.test.ts @@ -408,14 +408,14 @@ describe('batchLinkPlugin', () => { expect(subResponse2.headers['x-index']).toEqual('1') }) - it('separates GET and POST requests into distinct batches', async () => { + it('separates GET, QUERY, and unsafe requests into distinct batches', async () => { const codec = makeCodec() const transport = makeTransport() let callIndex = 0 vi.mocked(codec.encodeInput).mockImplementation(async () => { callIndex++ - const method = callIndex <= 2 ? 'GET' : 'POST' + const method = callIndex <= 2 ? 'GET' : callIndex <= 4 ? 'QUERY' : 'PUT' return { method, url: `/test-${callIndex}` as `/${string}`, @@ -441,19 +441,23 @@ describe('batchLinkPlugin', () => { await Promise.all([ link.call(['get1'], {}, { context: {} }), link.call(['get2'], {}, { context: {} }), - link.call(['post1'], {}, { context: {} }), - link.call(['post2'], {}, { context: {} }), + link.call(['query1'], {}, { context: {} }), + link.call(['query2'], {}, { context: {} }), + link.call(['put1'], {}, { context: {} }), + link.call(['put2'], {}, { context: {} }), ]) - // Should have at least 2 batch calls: one for GET, one for POST - expect(transport.send).toHaveBeenCalledTimes(2) + expect(transport.send).toHaveBeenCalledTimes(3) const sentGetRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'GET')![0] - expect(sentGetRequest).toBeDefined() + expect(extractBatchMessagesFromRequest(sentGetRequest).map(message => message.json.method)).toEqual(['GET', 'GET']) expect(sentGetRequest.headers['orpc-batch']).toBe('buffered') + const sentQueryRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'QUERY')![0] + expect(extractBatchMessagesFromRequest(sentQueryRequest).map(message => message.json.method)).toEqual(['QUERY', 'QUERY']) + const sentPostRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'POST')![0] - expect(sentPostRequest).toBeDefined() + expect(extractBatchMessagesFromRequest(sentPostRequest).map(message => message.json.method)).toEqual(['PUT', 'PUT']) expect(sentPostRequest.headers['orpc-batch']).toBe('buffered') }) diff --git a/packages/client/src/plugins/batch.ts b/packages/client/src/plugins/batch.ts index 23e81dbc7..d5a4f6827 100644 --- a/packages/client/src/plugins/batch.ts +++ b/packages/client/src/plugins/batch.ts @@ -217,15 +217,17 @@ export class BatchLinkPlugin implements StandardLinkPlu for (const [group, items] of pending) { const getItems = items.filter(([options]) => options.request.method === 'GET') - const restItems = items.filter(([options]) => options.request.method !== 'GET') + const queryItems = items.filter(([options]) => options.request.method === 'QUERY') + const unsafeItems = items.filter(([options]) => options.request.method !== 'GET' && options.request.method !== 'QUERY') this.executeBatch('GET', group, getItems) - this.executeBatch('POST', group, restItems) + this.executeBatch('QUERY', group, queryItems) + this.executeBatch('POST', group, unsafeItems) } } private async executeBatch( - method: 'GET' | 'POST', + method: 'GET' | 'QUERY' | 'POST', group: BatchLinkPluginGroup, groupItems: typeof this.queue extends Map ? U : never, ): Promise { diff --git a/packages/server/src/plugins/batch.test.ts b/packages/server/src/plugins/batch.test.ts index 49834591b..014bdf0f3 100644 --- a/packages/server/src/plugins/batch.test.ts +++ b/packages/server/src/plugins/batch.test.ts @@ -20,7 +20,7 @@ function makePeerRequestMessage(id: number, url: string, method = 'POST', body?: function createBatchRequest(options: { mode: 'buffered' | 'streaming' messages?: unknown - method?: 'POST' | 'GET' + method?: 'POST' | 'GET' | 'QUERY' data?: string }) { if (options.method === 'GET') { @@ -33,7 +33,7 @@ function createBatchRequest(options: { } return new Request('https://example.com/__batch__', { - method: 'POST', + method: options.method ?? 'POST', headers: { 'orpc-batch': options.mode, 'content-type': 'application/json' }, body: JSON.stringify(options.messages), }) @@ -344,6 +344,25 @@ describe('batchHandlerPlugin', () => { }) }) + describe('query batches', () => { + it('returns 400 before execution when a QUERY batch contains a non-QUERY sub-request', async () => { + const handler = createHandler() + + const { response } = await handler.handle(createBatchRequest({ + mode: 'buffered', + method: 'QUERY', + messages: [ + makePeerRequestMessage(0, '/ping', 'QUERY'), + makePeerRequestMessage(1, '/ping', 'POST'), + ], + })) + + expect(response!.status).toBe(400) + expect(await response!.text()).toContain('QUERY batch requests only accept QUERY sub-requests') + expect(handlerFn).toHaveBeenCalledTimes(0) + }) + }) + describe('configuration options', () => { it('supports custom successStatus', async () => { const handler = createHandler(new BatchHandlerPlugin({ successStatus: 200 })) diff --git a/packages/server/src/plugins/batch.ts b/packages/server/src/plugins/batch.ts index 94e1548ed..0c9c147bf 100644 --- a/packages/server/src/plugins/batch.ts +++ b/packages/server/src/plugins/batch.ts @@ -148,18 +148,6 @@ export class BatchHandlerPlugin implements StandardHandlerPlu } } - /** - * A GET batch must not execute non-GET sub-requests, otherwise defenses - * that treat GET as safe (CSRF protections, caches, method-based rules) - * can be bypassed. An absent method defaults to POST, so require an explicit GET. - */ - if (mightBeMessages.some(m => m.kind === 'request' && m.json.method !== 'GET')) { - return { - matched: true, - response: { status: 400, headers: {}, body: 'GET batch requests only accept GET sub-requests' }, - } - } - messages = mightBeMessages } else { @@ -182,6 +170,17 @@ export class BatchHandlerPlugin implements StandardHandlerPlu } } + const outerMethod = interceptorOptions.request.method + if ( + (outerMethod === 'GET' || outerMethod === 'QUERY') + && messages.some(message => message.kind === 'request' && message.json.method !== outerMethod) + ) { + return { + matched: true, + response: { status: 400, headers: {}, body: `${outerMethod} batch requests only accept ${outerMethod} sub-requests` }, + } + } + const maxSize = await value(this.maxSize, interceptorOptions) if (messages.length > maxSize) { diff --git a/tests/batch/__shared__/client-server.hono-fetch.ts b/tests/batch/__shared__/client-server.hono-fetch.ts index 24362f22c..c311c858a 100644 --- a/tests/batch/__shared__/client-server.hono-fetch.ts +++ b/tests/batch/__shared__/client-server.hono-fetch.ts @@ -12,13 +12,14 @@ export const createHonoFetchBatchClientServerTest: CreateBatchClientServerTest = router, { context = defaultBatchClientServerOptions.context, + method = 'GET', mode = defaultBatchClientServerOptions.mode, serializer = defaultBatchClientServerOptions.serializer, } = {}, ) => { const handler = new RPCHandler(router, { serializer, - allowMethods: ['GET', 'POST'], // the client below sends GET requests (POST as fallback) + allowMethods: ['GET', 'POST', 'QUERY'], plugins: [new BatchHandlerPlugin()], }) @@ -43,7 +44,7 @@ export const createHonoFetchBatchClientServerTest: CreateBatchClientServerTest = const link = new RPCLink({ url: '/rpc', - method: 'GET', // hono-fetch use GET while node-http use POST for better coverage + method, // hono-fetch uses GET by default while node-http uses POST for better coverage origin: `http://localhost:${addressInfo.port}`, serializer, fetch: fetchSpy, diff --git a/tests/batch/__shared__/client-server.node-http.ts b/tests/batch/__shared__/client-server.node-http.ts index ebb3ffe47..964eabeb6 100644 --- a/tests/batch/__shared__/client-server.node-http.ts +++ b/tests/batch/__shared__/client-server.node-http.ts @@ -12,12 +12,14 @@ export const createNodeHttpBatchClientServerTest: CreateBatchClientServerTest = router, { context = defaultBatchClientServerOptions.context, + method = 'POST', mode = defaultBatchClientServerOptions.mode, serializer = defaultBatchClientServerOptions.serializer, } = {}, ) => { const handler = new RPCHandler(router, { serializer, + allowMethods: ['POST', 'PUT', 'PATCH', 'DELETE', 'QUERY'], plugins: [new BatchHandlerPlugin()], }) @@ -40,6 +42,7 @@ export const createNodeHttpBatchClientServerTest: CreateBatchClientServerTest = const link = new RPCLink({ url: '/rpc', origin: `http://localhost:${addressInfo.port}`, + method, serializer, fetch: fetchSpy, plugins: [new BatchLinkPlugin({ groups: [defaultBatchGroup], mode })], diff --git a/tests/batch/__shared__/client-server.ts b/tests/batch/__shared__/client-server.ts index c95c3f90a..0dd4dfb52 100644 --- a/tests/batch/__shared__/client-server.ts +++ b/tests/batch/__shared__/client-server.ts @@ -5,6 +5,7 @@ import { defaultSerializer } from '../../rpc/__shared__/client-server' export interface BatchClientServerTestOptions { context?: Context + method?: 'GET' | 'POST' | 'QUERY' mode?: BatchLinkPluginMode serializer?: Pick } diff --git a/tests/batch/batch-plugin.test.ts b/tests/batch/batch-plugin.test.ts index dffe78acd..5f03064cd 100644 --- a/tests/batch/batch-plugin.test.ts +++ b/tests/batch/batch-plugin.test.ts @@ -185,3 +185,21 @@ describe.each([ expect(fetchSpy).toHaveBeenCalledTimes(1) // ensure batch was used }) }) + +describe('batch plugin: QUERY over node-http', () => { + it('round-trips concurrent calls through one outer QUERY request', async () => { + const router = { + echo: os.input(z.string()).handler(({ input }) => `echo:${input}`), + } + + const { client, fetchSpy } = createNodeHttpBatchClientServerTest(router, { method: 'QUERY' }) + + await Promise.all([ + expect(client.echo('alpha')).resolves.toBe('echo:alpha'), + expect(client.echo('beta')).resolves.toBe('echo:beta'), + ]) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]![1]).toEqual(expect.objectContaining({ method: 'QUERY' })) + }) +}) From 23888e95aaa9bbc3c6a4bcc2adefbaeca812899e Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 12:44:25 +0200 Subject: [PATCH 05/14] feat(bun): add QUERY search example --- playgrounds/bun/src/index.ts | 1 + playgrounds/bun/src/routers/index.ts | 3 ++- playgrounds/bun/src/routers/planet.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/playgrounds/bun/src/index.ts b/playgrounds/bun/src/index.ts index cddd64078..34c950ba8 100644 --- a/playgrounds/bun/src/index.ts +++ b/playgrounds/bun/src/index.ts @@ -30,6 +30,7 @@ const openapiHandler = new OpenAPIHandler(router, { new OpenAPIReferenceHandlerPlugin({ spec: () => openapiGenerator.generate(router, { base: { + openapi: '3.2.0', servers: [{ url: '/api' }], components: { securitySchemes: { diff --git a/playgrounds/bun/src/routers/index.ts b/playgrounds/bun/src/routers/index.ts index fcd97c9aa..73b9aa0aa 100644 --- a/playgrounds/bun/src/routers/index.ts +++ b/playgrounds/bun/src/routers/index.ts @@ -1,6 +1,6 @@ import { deleteFile, findFile, uploadFile } from './file' import { publishMessage, subscribeMessages } from './message' -import { createPlanet, deletePlanet, findPlanet, listPlanets, updatePlanet } from './planet' +import { createPlanet, deletePlanet, findPlanet, listPlanets, searchPlanets, updatePlanet } from './planet' export const router = { file: { @@ -11,6 +11,7 @@ export const router = { planet: { list: listPlanets, + search: searchPlanets, find: findPlanet, create: createPlanet, update: updatePlanet, diff --git a/playgrounds/bun/src/routers/planet.ts b/playgrounds/bun/src/routers/planet.ts index 84170f093..c916791e8 100644 --- a/playgrounds/bun/src/routers/planet.ts +++ b/playgrounds/bun/src/routers/planet.ts @@ -40,6 +40,21 @@ export const listPlanets = publicOS return planets.slice(cursor, cursor + limit) }) +export const searchPlanets = publicOS + .meta(openapi({ + method: 'QUERY', + path: '/planets/search', + summary: 'Search planets by name', + tags: ['Planet'], + })) + .input(z.object({ + names: z.array(z.string()), + })) + .output(z.array(PlanetSchema)) + .handler(({ input }) => { + return DB.filter(planet => input.names.includes(planet.name)) + }) + export const findPlanet = publicOS .meta(openapi({ method: 'GET', From 9f470f988c5ab873fc2a0a30f2d6e57bed450d2d Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 13:24:29 +0200 Subject: [PATCH 06/14] fix(server): validate body-encoded batch messages --- packages/server/src/plugins/batch.test.ts | 16 ++++++++++++++++ packages/server/src/plugins/batch.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/server/src/plugins/batch.test.ts b/packages/server/src/plugins/batch.test.ts index 014bdf0f3..61eaa0a6e 100644 --- a/packages/server/src/plugins/batch.test.ts +++ b/packages/server/src/plugins/batch.test.ts @@ -153,6 +153,7 @@ describe('batchHandlerPlugin', () => { })) expect(response!.status).toBe(400) + expect(await response!.text()).toContain('Invalid batch request body') expect(handlerFn).toHaveBeenCalledTimes(0) }) @@ -345,6 +346,21 @@ describe('batchHandlerPlugin', () => { }) describe('query batches', () => { + it('returns 400 before execution when a QUERY batch contains a non-request message', async () => { + const handler = createHandler() + + const { response } = await handler.handle(createBatchRequest({ + mode: 'buffered', + method: 'QUERY', + messages: [ + { kind: 'response', id: 0, json: { method: 'POST', url: '/ping', headers: {} } }, + ], + })) + + expect(response!.status).toBe(400) + expect(handlerFn).toHaveBeenCalledTimes(0) + }) + it('returns 400 before execution when a QUERY batch contains a non-QUERY sub-request', async () => { const handler = createHandler() diff --git a/packages/server/src/plugins/batch.ts b/packages/server/src/plugins/batch.ts index 0c9c147bf..95183f1c9 100644 --- a/packages/server/src/plugins/batch.ts +++ b/packages/server/src/plugins/batch.ts @@ -153,7 +153,7 @@ export class BatchHandlerPlugin implements StandardHandlerPlu else { const mightBeMessages = await interceptorOptions.request.resolveBody() - if (!Array.isArray(mightBeMessages)) { + if (!Array.isArray(mightBeMessages) || mightBeMessages.some(m => !isClientPeerSendMessage(m))) { return { matched: true, response: { status: 400, headers: {}, body: 'Invalid batch request body' }, From 2a0ccdab2af29c64cc4b562d59bd57c39f2711e6 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 16:54:29 +0200 Subject: [PATCH 07/14] docs: remove QUERY method guidance --- apps/content/docs/openapi/routing.mdx | 15 ------------- apps/content/docs/openapi/specification.mdx | 24 ++------------------- apps/content/docs/plugins/batch.mdx | 12 ----------- apps/content/docs/plugins/cors.mdx | 4 +--- apps/content/docs/plugins/dedupe.mdx | 2 +- apps/content/docs/rpc/handler.mdx | 2 +- apps/content/docs/rpc/link.mdx | 21 ------------------ apps/content/docs/rpc/protocol.mdx | 2 +- 8 files changed, 6 insertions(+), 76 deletions(-) diff --git a/apps/content/docs/openapi/routing.mdx b/apps/content/docs/openapi/routing.mdx index a2943a657..08eabead2 100644 --- a/apps/content/docs/openapi/routing.mdx +++ b/apps/content/docs/openapi/routing.mdx @@ -25,21 +25,6 @@ const router = { In this example, `list` is exposed as `GET /planets` because it overrides the default method and path. `create` keeps the default behavior, so it is exposed as `POST /planet/create`. -### QUERY Method - -Use the [`QUERY` method defined by RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html) for safe, idempotent operations that need body-encoded input: - -```ts -const searchPlanets = os - .meta(openapi({ method: 'QUERY', path: '/planets/search' })) - .input(z.object({ names: z.array(z.string()) })) - .handler(async ({ input }) => findPlanets(input.names)) -``` - -The OpenAPI client sends compact QUERY input in the request body, and the OpenAPI handler reads it from there. Documenting QUERY requires an [explicit OpenAPI 3.2 base document](/docs/openapi/specification#query-operations). - -QUERY works only when every HTTP runtime, framework, proxy, gateway, and client in the request path accepts and forwards the method. - ## Path Parameters To define a path parameter, use `{name}` in the `path` and add the same field as a required key in the input schema: diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 5bc105255..565c19123 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -1,6 +1,6 @@ --- title: "OpenAPI Specification" -description: "Learn how to configure openapi metadata and generate OpenAPI documents from your oRPC contracts and routers with OpenAPIGenerator." +description: "Learn how to configure openapi metadata and generate OpenAPI 3.1 documents from your oRPC contracts and routers with OpenAPIGenerator." --- ## Metadata @@ -131,7 +131,7 @@ In this example, the final `tags` is `undefined`, so no tags are applied to `exa ## OpenAPI Generator -`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI document. It uses OpenAPI 3.1.2 by default. +`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document. ```ts import { OpenAPIGenerator } from '@orpc/openapi' @@ -153,26 +153,6 @@ const spec = await generator.generate(router, { }) ``` -### QUERY Operations - -OpenAPI defines the `query` Path Item field starting in OpenAPI 3.2. When a router contains a [`QUERY` operation](/docs/openapi/routing#query-method), explicitly select OpenAPI 3.2 in the base document: - -```ts -const spec = await generator.generate(router, { - base: { - openapi: '3.2.0', - info: { - title: 'Planet API', - version: '1.0.0', - }, - }, -}) -``` - -Without `base.openapi: '3.2.0'`, generation fails rather than emitting the 3.2-only `query` field in the default OpenAPI 3.1.2 document. Routers without QUERY operations continue to generate OpenAPI 3.1.2 documents by default. - -oRPC's public document type includes only the OpenAPI 3.2 compatibility needed for QUERY Path Items; it does not claim complete OpenAPI 3.2 coverage. OpenAPI viewers, client generators, validators, HTTP runtimes, proxies, and gateways may not support OpenAPI 3.2 or the QUERY method yet. Verify every tool and network hop used by your API before adopting it. - ### Json Schema Converters `OpenAPIGenerator` relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides dedicated converters through the [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), and [ArkType](/docs/integrations/arktype) integrations: diff --git a/apps/content/docs/plugins/batch.mdx b/apps/content/docs/plugins/batch.mdx index 07b37f480..fcce50d59 100644 --- a/apps/content/docs/plugins/batch.mdx +++ b/apps/content/docs/plugins/batch.mdx @@ -56,18 +56,6 @@ const cors = new CORSHandlerPlugin({ ::: -## Request Methods - -Within each configured group, the client keeps request methods in separate batches: - -- `GET` calls use an outer GET request with the batch data encoded in the URL. -- `QUERY` calls use a body-bearing outer QUERY request. -- Unsafe calls use an outer POST request. - -GET, QUERY, and unsafe calls are never mixed in one batch. The server also requires every request message inside a safe outer GET or QUERY batch to use that same method, and rejects the entire malformed batch before any procedure executes. This preserves method-based security, caching, and policy boundaries. - -QUERY remains opt-in on the RPC handler. Add it to the handler's [`allowMethods`](/docs/rpc/handler#supported-http-methods) and only use it for safe, idempotent procedures. QUERY batching works only when every HTTP runtime, framework, proxy, and gateway in the request path accepts and forwards custom methods. - ## Response Modes By default, the plugin uses `streaming` mode. Responses are sent as soon as they are ready, so one slow request does not block the rest of the batch. diff --git a/apps/content/docs/plugins/cors.mdx b/apps/content/docs/plugins/cors.mdx index 5c0a3b68d..c6c3d4bbf 100644 --- a/apps/content/docs/plugins/cors.mdx +++ b/apps/content/docs/plugins/cors.mdx @@ -17,7 +17,7 @@ const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: (origin, options) => origin, - allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'], + allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], // ... }), ], @@ -26,8 +26,6 @@ const handler = new RPCHandler(router, { :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. - -The default `allowMethods` list includes [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html). Browser QUERY requests are not CORS-safelisted, so they require a preflight response that advertises the method. Providing `allowMethods` replaces the default list. ::: :::warning diff --git a/apps/content/docs/plugins/dedupe.mdx b/apps/content/docs/plugins/dedupe.mdx index b2c727351..f85d4d179 100644 --- a/apps/content/docs/plugins/dedupe.mdx +++ b/apps/content/docs/plugins/dedupe.mdx @@ -30,7 +30,7 @@ The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [O ## Filter -By default, the plugin deduplicates `GET` and `QUERY` requests. You can customize this behavior by providing a `filter` function. +By default, the plugin deduplicates only `GET` requests. You can customize this behavior by providing a `filter` function. ```ts const link = new RPCLink({ diff --git a/apps/content/docs/rpc/handler.mdx b/apps/content/docs/rpc/handler.mdx index d743194b2..32d309824 100644 --- a/apps/content/docs/rpc/handler.mdx +++ b/apps/content/docs/rpc/handler.mdx @@ -70,7 +70,7 @@ By default, `RPCHandler` only responds to `POST`, `PUT`, `PATCH`, and `DELETE` r This is a security default: cross-site, browsers can only send these methods via a [CORS preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) or an HTML form, never from a plain link. Safe methods like `GET` or `HEAD` are excluded because invoking a procedure can modify data. -Use `allowMethods` to replace the allowlist: tighten it to `POST` only, or explicitly accept [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html), which is a safe, idempotent method that reads input from the request body and stays preflight-protected. Only enable QUERY for procedures that satisfy those semantics. It remains excluded from the default allowlist, and support depends on every HTTP runtime, framework, proxy, and gateway in the request path accepting and forwarding custom methods: +Use `allowMethods` to replace the allowlist: tighten it to `POST` only, or also accept [`QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/), which reads input from the request body and stays preflight-protected: ```ts const handler = new RPCHandler(router, { diff --git a/apps/content/docs/rpc/link.mdx b/apps/content/docs/rpc/link.mdx index 43976fac9..bf46f5f41 100644 --- a/apps/content/docs/rpc/link.mdx +++ b/apps/content/docs/rpc/link.mdx @@ -263,29 +263,8 @@ const link = new RPCLink({ `RPCLink` sends requests with `POST` by default. Use `method` to choose the method per call. -To send safe, idempotent requests with body-encoded input, choose the [`QUERY` method defined by RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html): - -```ts -const link = new RPCLink({ - url: '/rpc', - method: 'QUERY', -}) -``` - -When using `GET`, input is encoded in the URL. If the URL exceeds `maxUrlLength` or cannot carry the serialized input, `RPCLink` sends the input in a request body using `fallbackMethod`. The fallback defaults to `POST`; choose `QUERY` explicitly when the procedure is safe and idempotent: - -```ts -const link = new RPCLink({ - url: '/rpc', - method: 'GET', - fallbackMethod: 'QUERY', -}) -``` - :::warning By default, [RPC handlers](/docs/rpc/handler#supported-http-methods) only accept `POST`, `PUT`, `PATCH`, and `DELETE` requests. Before sending `GET` or `QUERY` requests, allow them in the handler first, and understand [the risks of enabling `GET`](/docs/rpc/handler#enabling-the-get-method). - -Browser `QUERY` requests require a CORS preflight. QUERY also works only when every HTTP runtime, framework, proxy, and gateway in the request path accepts and forwards custom methods. ::: ```ts diff --git a/apps/content/docs/rpc/protocol.mdx b/apps/content/docs/rpc/protocol.mdx index 7d87089a2..da9cd294a 100644 --- a/apps/content/docs/rpc/protocol.mdx +++ b/apps/content/docs/rpc/protocol.mdx @@ -42,7 +42,7 @@ const router = { ## Sending Input -Requests can use the `POST`, `PUT`, `PATCH`, or `DELETE` method, or other methods like `GET` and [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) when the server [allows them](/docs/rpc/handler#supported-http-methods). Send input in the query string for `GET` and in the request body for all other methods. QUERY requires explicit server opt-in and an HTTP runtime that accepts custom methods. +Requests can use the `POST`, `PUT`, `PATCH`, or `DELETE` method, or other methods like `GET` and `QUERY` when the server [allows them](/docs/rpc/handler#supported-http-methods). Send input in the query string (`GET`) or request body (other methods). :::info Request payloads depend on the serializer and are not plain JSON. Learn more in [RPC Serializer Format](/docs/rpc/serializer#serialization-format). From 2ac80f4040e334fcb6a7332da290e999d171d128 Mon Sep 17 00:00:00 2001 From: aldotestino Date: Tue, 11 Aug 2026 17:04:23 +0200 Subject: [PATCH 08/14] docs: correct default dedupe methods --- apps/content/docs/plugins/dedupe.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/plugins/dedupe.mdx b/apps/content/docs/plugins/dedupe.mdx index f85d4d179..b2c727351 100644 --- a/apps/content/docs/plugins/dedupe.mdx +++ b/apps/content/docs/plugins/dedupe.mdx @@ -30,7 +30,7 @@ The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [O ## Filter -By default, the plugin deduplicates only `GET` requests. You can customize this behavior by providing a `filter` function. +By default, the plugin deduplicates `GET` and `QUERY` requests. You can customize this behavior by providing a `filter` function. ```ts const link = new RPCLink({ From 4110b782776357156aa9cadb1c03bec1814cfc44 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 12 Aug 2026 13:29:55 +0700 Subject: [PATCH 09/14] docs(openapi): enhance OpenAPIDocument to support QUERY method in OpenAPI 3.2 --- apps/content/docs/openapi/specification.mdx | 14 +++++++++++++- packages/openapi/src/types.test-d.ts | 5 +++-- packages/openapi/src/types.ts | 14 +++++--------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 565c19123..70a3de0bd 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -131,7 +131,7 @@ In this example, the final `tags` is `undefined`, so no tags are applied to `exa ## OpenAPI Generator -`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document. +`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document by default. OpenAPI 3.2 is partially supported. ```ts import { OpenAPIGenerator } from '@orpc/openapi' @@ -153,6 +153,18 @@ const spec = await generator.generate(router, { }) ``` +### QUERY method + +If your router contains a procedure that uses the `QUERY` method, explicitly set the OpenAPI version to `3.2.0`, because OpenAPI 3.1 does not support `QUERY`. + +```ts +const spec = await generator.generate(router, { + base: { + openapi: '3.2.0', + }, +}) +``` + ### Json Schema Converters `OpenAPIGenerator` relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides dedicated converters through the [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), and [ArkType](/docs/integrations/arktype) integrations: diff --git a/packages/openapi/src/types.test-d.ts b/packages/openapi/src/types.test-d.ts index b07401a76..c7990ad9c 100644 --- a/packages/openapi/src/types.test-d.ts +++ b/packages/openapi/src/types.test-d.ts @@ -1,7 +1,7 @@ import type { Client, ORPCError } from '@orpc/client' import type { RouterContractClient } from '@orpc/contract' import type { AsyncIteratorClass } from '@orpc/shared' -import type { JsonifiedClient, JsonifiedValue, OpenAPIDocument, OpenAPIOperationObject } from './types' +import type { JsonifiedClient, JsonifiedValue, OpenAPIDocument } from './types' import { asyncIteratorObject, oc } from '@orpc/contract' import z from 'zod' @@ -9,7 +9,8 @@ describe('OpenAPIDocument', () => { it('narrowly supports OpenAPI 3.2 QUERY path items', () => { expectTypeOf().toEqualTypeOf<'3.1.0' | '3.1.1' | '3.1.2' | '3.2.0'>() - expectTypeOf['/search']['query']>().toEqualTypeOf() + expectTypeOf['/search']['query']>() + .toEqualTypeOf['/search']['post']>() }) }) diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index bbe1cfe5b..6ecb2b9d1 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -5,21 +5,17 @@ import type { AsyncIteratorClass } from '@orpc/shared' export type OpenAPIOperationObject = OpenAPIV3_1.OperationObject -export type OpenAPIPathItemObject = OpenAPIV3_1.PathItemObject & { - query?: OpenAPIOperationObject | undefined -} - -export type OpenAPIPathsObject = OpenAPIV3_1.PathsObject & { - [path: `/${string}`]: OpenAPIPathItemObject -} - /** * An OpenAPI 3.1 document with the OpenAPI 3.2 QUERY additions used by oRPC. * This type does not claim support for the complete OpenAPI 3.2 specification. */ export type OpenAPIDocument = Omit & { openapi: OpenAPIV3_1.Document['openapi'] | '3.2.0' - paths?: OpenAPIPathsObject | undefined + paths?: undefined | OpenAPIV3_1.PathsObject & { + [path: `/${string}`]: OpenAPIV3_1.PathItemObject & { + query?: OpenAPIOperationObject | undefined + } + } } export type JsonifiedValue From 01bdcd4686542bb3d79a8bf144456a8513ae3b17 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 12 Aug 2026 13:35:30 +0700 Subject: [PATCH 10/14] remove query procedure in playground --- playgrounds/bun/src/index.ts | 1 - playgrounds/bun/src/routers/index.ts | 3 +-- playgrounds/bun/src/routers/planet.ts | 15 --------------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/playgrounds/bun/src/index.ts b/playgrounds/bun/src/index.ts index 34c950ba8..cddd64078 100644 --- a/playgrounds/bun/src/index.ts +++ b/playgrounds/bun/src/index.ts @@ -30,7 +30,6 @@ const openapiHandler = new OpenAPIHandler(router, { new OpenAPIReferenceHandlerPlugin({ spec: () => openapiGenerator.generate(router, { base: { - openapi: '3.2.0', servers: [{ url: '/api' }], components: { securitySchemes: { diff --git a/playgrounds/bun/src/routers/index.ts b/playgrounds/bun/src/routers/index.ts index 73b9aa0aa..fcd97c9aa 100644 --- a/playgrounds/bun/src/routers/index.ts +++ b/playgrounds/bun/src/routers/index.ts @@ -1,6 +1,6 @@ import { deleteFile, findFile, uploadFile } from './file' import { publishMessage, subscribeMessages } from './message' -import { createPlanet, deletePlanet, findPlanet, listPlanets, searchPlanets, updatePlanet } from './planet' +import { createPlanet, deletePlanet, findPlanet, listPlanets, updatePlanet } from './planet' export const router = { file: { @@ -11,7 +11,6 @@ export const router = { planet: { list: listPlanets, - search: searchPlanets, find: findPlanet, create: createPlanet, update: updatePlanet, diff --git a/playgrounds/bun/src/routers/planet.ts b/playgrounds/bun/src/routers/planet.ts index c916791e8..84170f093 100644 --- a/playgrounds/bun/src/routers/planet.ts +++ b/playgrounds/bun/src/routers/planet.ts @@ -40,21 +40,6 @@ export const listPlanets = publicOS return planets.slice(cursor, cursor + limit) }) -export const searchPlanets = publicOS - .meta(openapi({ - method: 'QUERY', - path: '/planets/search', - summary: 'Search planets by name', - tags: ['Planet'], - })) - .input(z.object({ - names: z.array(z.string()), - })) - .output(z.array(PlanetSchema)) - .handler(({ input }) => { - return DB.filter(planet => input.names.includes(planet.name)) - }) - export const findPlanet = publicOS .meta(openapi({ method: 'GET', From 3469719f8fcc7b7cf3c89f422fc673808b126007 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 12 Aug 2026 13:45:39 +0700 Subject: [PATCH 11/14] fix(nest): prevent usage of QUERY HTTP method in @Implement decorator --- packages/nest/src/implement.test.ts | 17 +++++++++++++++++ packages/nest/src/implement.ts | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index b369ebee4..415c8ebf5 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -40,6 +40,23 @@ describe('requirements', () => { }).toThrow(/openapi\.path/) }) + it('should throw if @Implement uses the QUERY HTTP method', () => { + const contract = oc.meta(openapi({ + path: '/procedure', + method: 'QUERY', + })) + + expect(() => { + @Controller() + class ImplController { + @Implement(contract) + procedure() { + return implement(contract).handler(() => {}) + } + } + }).toThrow(/does not support the 'QUERY' HTTP method/) + }) + it('should error if implemented method return invalid procedure', async () => { const contract = oc.meta(openapi({ path: '/procedure', diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index a7a9eb437..4f1aeaf80 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -83,6 +83,13 @@ function toNestRouteDecorator(contract: AnyProcedureContract): MethodDecorator { const path = toNestPattern(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path) const successStatus = meta.successStatus ?? DEFAULT_SUCCESS_STATUS + if (method === 'QUERY') { + throw new TypeError(` + @Implement decorator does not support the 'QUERY' HTTP method because NestJS does not support it. + Use the 'GET' method instead. + `) + } + return applyDecorators( MethodDecoratorMap[method](path), HttpCode(successStatus), From ae04463fb56d73448cb23d5e0f540a7351e14355 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 12 Aug 2026 13:48:57 +0700 Subject: [PATCH 12/14] docs(openapi): simplify OpenAPIGenerator documentation comment --- packages/openapi/src/openapi-generator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index aa6c11fa4..51338a490 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -78,8 +78,7 @@ export interface OpenAPIGeneratorGenerateOptions { } /** - * Generates an OpenAPI 3.1 document, or an OpenAPI 3.2 document for QUERY operations, - * from a contract or a router. + * Generates an OpenAPI document from a contract or router. * Relies on JSON schema converters to translate input, output, and error schemas into JSON Schemas. * * @see {@link https://orpc.dev/docs/openapi/specification#openapi-generator | OpenAPI Specification - OpenAPI Generator} From 8745cd97a999e904b031a599d27bbe89032c666c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 12 Aug 2026 14:36:43 +0700 Subject: [PATCH 13/14] improve --- apps/content/docs/plugins/cors.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/plugins/cors.mdx b/apps/content/docs/plugins/cors.mdx index c6c3d4bbf..c4b826262 100644 --- a/apps/content/docs/plugins/cors.mdx +++ b/apps/content/docs/plugins/cors.mdx @@ -17,7 +17,7 @@ const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: (origin, options) => origin, - allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], + allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'], // ... }), ], From f9a791365ccfa5bd1a9104f5e09289a60e632a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=8Bnh=20L=C3=AA?= Date: Wed, 12 Aug 2026 15:00:57 +0700 Subject: [PATCH 14/14] chore Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/batch/__shared__/client-server.node-http.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/batch/__shared__/client-server.node-http.ts b/tests/batch/__shared__/client-server.node-http.ts index 964eabeb6..429a1eb08 100644 --- a/tests/batch/__shared__/client-server.node-http.ts +++ b/tests/batch/__shared__/client-server.node-http.ts @@ -19,7 +19,7 @@ export const createNodeHttpBatchClientServerTest: CreateBatchClientServerTest = ) => { const handler = new RPCHandler(router, { serializer, - allowMethods: ['POST', 'PUT', 'PATCH', 'DELETE', 'QUERY'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'QUERY'], plugins: [new BatchHandlerPlugin()], })