Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/content/docs/openapi/routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 22 additions & 2 deletions apps/content/docs/openapi/specification.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions apps/content/docs/plugins/batch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion apps/content/docs/plugins/cors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
// ...
}),
],
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/content/docs/plugins/dedupe.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/content/docs/rpc/handler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
21 changes: 21 additions & 0 deletions apps/content/docs/rpc/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/content/docs/rpc/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions packages/client/src/adapters/fetch/rpc-link.test-d.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
29 changes: 29 additions & 0 deletions packages/client/src/adapters/fetch/rpc-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }), {
Expand Down
31 changes: 31 additions & 0 deletions packages/client/src/adapters/standard/rpc-link-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/adapters/standard/rpc-link-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ export interface RPCLinkCodecOptions<T extends ClientContext> {
*
* @default 'POST'
*/
method?: Value<Promisable<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>, [options: ClientOptions<T>, path: string[], input: unknown]>
method?: Value<Promisable<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'>, [options: ClientOptions<T>, path: string[], input: unknown]>

/**
* The method to use when the payload cannot safely pass to the server with method return from method function.
* GET is not allowed, it's very dangerous.
*
* @default 'POST'
*/
fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'

/**
* Inject headers to the request.
Expand Down
20 changes: 12 additions & 8 deletions packages/client/src/plugins/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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')
})

Expand Down
8 changes: 5 additions & 3 deletions packages/client/src/plugins/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,17 @@ export class BatchLinkPlugin<T extends ClientContext> 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<T>,
groupItems: typeof this.queue extends Map<any, infer U> ? U : never,
): Promise<void> {
Expand Down
57 changes: 57 additions & 0 deletions packages/client/src/plugins/dedupe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading