Skip to content

Commit 2bc387b

Browse files
aldotestinodinwwwhCopilot
authored
feat(rpc,openapi): add end-to-end HTTP QUERY support (#1844)
Fixes #1841 This PR adds end-to-end support for the HTTP `QUERY` method across oRPC RPC links, OpenAPI routes, request deduplication, batching, CORS, and tests. [`QUERY` is defined by RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html) as a safe and idempotent method that carries query input in the request body. It fills the gap between `GET`, which is safe but puts input in the URL, and `POST`, which supports a body but does not communicate safe or idempotent semantics. --------- Co-authored-by: Dinh Le <dinwwwh@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 1d6dbe6 commit 2bc387b

27 files changed

Lines changed: 399 additions & 43 deletions

apps/content/docs/openapi/specification.mdx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ In this example, the final `tags` is `undefined`, so no tags are applied to `exa
131131

132132
## OpenAPI Generator
133133

134-
`OpenAPIGenerator` accepts either a [contract](/docs/contract/router) or a [router](/docs/router) and generates an OpenAPI 3.1 document.
134+
`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.
135135

136136
```ts
137137
import { OpenAPIGenerator } from '@orpc/openapi'
@@ -153,6 +153,18 @@ const spec = await generator.generate(router, {
153153
})
154154
```
155155

156+
### QUERY method
157+
158+
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`.
159+
160+
```ts
161+
const spec = await generator.generate(router, {
162+
base: {
163+
openapi: '3.2.0',
164+
},
165+
})
166+
```
167+
156168
### Json Schema Converters
157169

158170
`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:

apps/content/docs/plugins/cors.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const handler = new RPCHandler(router, {
1717
plugins: [
1818
new CORSHandlerPlugin({
1919
origin: (origin, options) => origin,
20-
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'],
20+
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'],
2121
// ...
2222
}),
2323
],

apps/content/docs/plugins/dedupe.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [O
3030

3131
## Filter
3232

33-
By default, the plugin deduplicates only `GET` requests. You can customize this behavior by providing a `filter` function.
33+
By default, the plugin deduplicates `GET` and `QUERY` requests. You can customize this behavior by providing a `filter` function.
3434

3535
```ts
3636
const link = new RPCLink({
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { RPCLink } from './rpc-link'
2+
3+
describe('RPCLink', () => {
4+
it('accepts QUERY only for body-bearing request methods', () => {
5+
void new RPCLink({ method: 'QUERY' })
6+
void new RPCLink({ method: 'GET', fallbackMethod: 'QUERY' })
7+
8+
// @ts-expect-error - HEAD is not a supported direct RPC request method
9+
void new RPCLink({ method: 'HEAD' })
10+
// @ts-expect-error - OPTIONS is not a supported direct RPC request method
11+
void new RPCLink({ method: 'OPTIONS' })
12+
// @ts-expect-error - GET cannot be used as a body-bearing fallback
13+
void new RPCLink({ fallbackMethod: 'GET' })
14+
})
15+
})

packages/client/src/adapters/fetch/rpc-link.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ describe('rpcLink', () => {
5656
)
5757
})
5858

59+
it('sends QUERY requests with body-encoded input', async () => {
60+
const fetch = vi.fn(async () => {
61+
return new Response(JSON.stringify({ json: 'pong' }), {
62+
status: 200,
63+
headers: {
64+
'content-type': 'application/json',
65+
},
66+
})
67+
})
68+
69+
const orpc = createORPCClient(new RPCLink({
70+
fetch,
71+
method: 'QUERY',
72+
origin: 'http://api.example.com',
73+
})) as any
74+
75+
await expect(orpc.ping('input')).resolves.toEqual('pong')
76+
77+
expect(fetch).toHaveBeenCalledWith(
78+
'http://api.example.com/ping',
79+
expect.objectContaining({
80+
body: JSON.stringify({ json: 'input' }),
81+
method: 'QUERY',
82+
}),
83+
expect.objectContaining({ context: {} }),
84+
['ping'],
85+
)
86+
})
87+
5988
it('supports custom headers and query parameters in origin', async () => {
6089
const fetch = vi.fn(async () => {
6190
return new Response(JSON.stringify({ json: 'pong' }), {

packages/client/src/adapters/standard/rpc-link-codec.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ describe('rpcLinkCodec', () => {
8888
expect(request.url).toBe('/api/ping')
8989
})
9090

91+
it('falls back to QUERY with a body when GET url exceeds maxUrlLength', async () => {
92+
const codec = new RPCLinkCodec({
93+
url: '/api',
94+
method: 'GET',
95+
maxUrlLength: 10,
96+
fallbackMethod: 'QUERY',
97+
serializer,
98+
})
99+
100+
const request = await codec.encodeInput('input', ['ping'], { context: {} })
101+
102+
expect(request.method).toBe('QUERY')
103+
expect(request.body).toBe(serializeSpy.mock.results[0]!.value)
104+
expect(request.url).toBe('/api/ping')
105+
})
106+
107+
it('falls back to POST by default when GET url exceeds maxUrlLength', async () => {
108+
const codec = new RPCLinkCodec({
109+
url: '/api',
110+
method: 'GET',
111+
maxUrlLength: 10,
112+
serializer,
113+
})
114+
115+
const request = await codec.encodeInput('input', ['ping'], { context: {} })
116+
117+
expect(request.method).toBe('POST')
118+
expect(request.body).toBe(serializeSpy.mock.results[0]!.value)
119+
expect(request.url).toBe('/api/ping')
120+
})
121+
91122
it.each([
92123
['FormData', () => {
93124
const f = new FormData()

packages/client/src/adapters/standard/rpc-link-codec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ export interface RPCLinkCodecOptions<T extends ClientContext> {
3232
*
3333
* @default 'POST'
3434
*/
35-
method?: Value<Promisable<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>, [options: ClientOptions<T>, path: string[], input: unknown]>
35+
method?: Value<Promisable<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'>, [options: ClientOptions<T>, path: string[], input: unknown]>
3636

3737
/**
3838
* The method to use when the payload cannot safely pass to the server with method return from method function.
3939
* GET is not allowed, it's very dangerous.
4040
*
4141
* @default 'POST'
4242
*/
43-
fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
43+
fallbackMethod?: 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'QUERY'
4444

4545
/**
4646
* Inject headers to the request.

packages/client/src/plugins/batch.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -451,14 +451,14 @@ describe('batchLinkPlugin', () => {
451451
expect(subResponse1.headers['x-from-batch-response']).toBeUndefined()
452452
})
453453

454-
it('separates GET and POST requests into distinct batches', async () => {
454+
it('separates GET, QUERY, and unsafe requests into distinct batches', async () => {
455455
const codec = makeCodec()
456456
const transport = makeTransport()
457457

458458
let callIndex = 0
459459
vi.mocked(codec.encodeInput).mockImplementation(async () => {
460460
callIndex++
461-
const method = callIndex <= 2 ? 'GET' : 'POST'
461+
const method = callIndex <= 2 ? 'GET' : callIndex <= 4 ? 'QUERY' : 'PUT'
462462
return {
463463
method,
464464
url: `/test-${callIndex}` as `/${string}`,
@@ -484,19 +484,23 @@ describe('batchLinkPlugin', () => {
484484
await Promise.all([
485485
link.call(['get1'], {}, { context: {} }),
486486
link.call(['get2'], {}, { context: {} }),
487-
link.call(['post1'], {}, { context: {} }),
488-
link.call(['post2'], {}, { context: {} }),
487+
link.call(['query1'], {}, { context: {} }),
488+
link.call(['query2'], {}, { context: {} }),
489+
link.call(['put1'], {}, { context: {} }),
490+
link.call(['put2'], {}, { context: {} }),
489491
])
490492

491-
// Should have at least 2 batch calls: one for GET, one for POST
492-
expect(transport.send).toHaveBeenCalledTimes(2)
493+
expect(transport.send).toHaveBeenCalledTimes(3)
493494

494495
const sentGetRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'GET')![0]
495-
expect(sentGetRequest).toBeDefined()
496+
expect(extractBatchMessagesFromRequest(sentGetRequest).map(message => message.json.method)).toEqual(['GET', 'GET'])
496497
expect(sentGetRequest.headers['orpc-batch']).toBe('buffered')
497498

499+
const sentQueryRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'QUERY')![0]
500+
expect(extractBatchMessagesFromRequest(sentQueryRequest).map(message => message.json.method)).toEqual(['QUERY', 'QUERY'])
501+
498502
const sentPostRequest = vi.mocked(transport.send).mock.calls.find(([request]) => request.method === 'POST')![0]
499-
expect(sentPostRequest).toBeDefined()
503+
expect(extractBatchMessagesFromRequest(sentPostRequest).map(message => message.json.method)).toEqual(['PUT', 'PUT'])
500504
expect(sentPostRequest.headers['orpc-batch']).toBe('buffered')
501505
})
502506

packages/client/src/plugins/batch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,17 @@ export class BatchLinkPlugin<T extends ClientContext> implements StandardLinkPlu
217217

218218
for (const [group, items] of pending) {
219219
const getItems = items.filter(([options]) => options.request.method === 'GET')
220-
const restItems = items.filter(([options]) => options.request.method !== 'GET')
220+
const queryItems = items.filter(([options]) => options.request.method === 'QUERY')
221+
const unsafeItems = items.filter(([options]) => options.request.method !== 'GET' && options.request.method !== 'QUERY')
221222

222223
this.executeBatch('GET', group, getItems)
223-
this.executeBatch('POST', group, restItems)
224+
this.executeBatch('QUERY', group, queryItems)
225+
this.executeBatch('POST', group, unsafeItems)
224226
}
225227
}
226228

227229
private async executeBatch(
228-
method: 'GET' | 'POST',
230+
method: 'GET' | 'QUERY' | 'POST',
229231
group: BatchLinkPluginGroup<T>,
230232
groupItems: typeof this.queue extends Map<any, infer U> ? U : never,
231233
): Promise<void> {

packages/client/src/plugins/dedupe.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,63 @@ describe('dedupeLinkPlugin', () => {
108108
}))
109109
})
110110

111+
it('dedupes identical QUERY requests by default', async () => {
112+
const codec = makeCodec()
113+
const transport = makeTransport()
114+
115+
const link = new StandardLink(codec, transport, {
116+
plugins: [new DedupeLinkPlugin({
117+
groups: [{ condition: () => true, context: { group: true } }],
118+
})],
119+
})
120+
121+
const [output1, output2] = await Promise.all([
122+
link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }),
123+
link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }),
124+
])
125+
126+
expect(output1).toBe(output2)
127+
expect(transport.send).toHaveBeenCalledTimes(1)
128+
})
129+
130+
it('does not dedupe QUERY requests with different bodies', async () => {
131+
const codec = makeCodec()
132+
const transport = makeTransport()
133+
134+
const link = new StandardLink(codec, transport, {
135+
plugins: [new DedupeLinkPlugin({
136+
groups: [{ condition: () => true, context: { group: true } }],
137+
})],
138+
})
139+
140+
const [output1, output2] = await Promise.all([
141+
link.call(['QUERY', 'planet'], { value: 1 }, { context: {} }),
142+
link.call(['QUERY', 'planet'], { value: 2 }, { context: {} }),
143+
])
144+
145+
expect(output1).not.toBe(output2)
146+
expect(transport.send).toHaveBeenCalledTimes(2)
147+
})
148+
149+
it('does not dedupe unsafe methods by default', async () => {
150+
const codec = makeCodec()
151+
const transport = makeTransport()
152+
153+
const link = new StandardLink(codec, transport, {
154+
plugins: [new DedupeLinkPlugin({
155+
groups: [{ condition: () => true, context: { group: true } }],
156+
})],
157+
})
158+
159+
const [output1, output2] = await Promise.all([
160+
link.call(['POST', 'planet'], { value: 1 }, { context: {} }),
161+
link.call(['POST', 'planet'], { value: 1 }, { context: {} }),
162+
])
163+
164+
expect(output1).not.toBe(output2)
165+
expect(transport.send).toHaveBeenCalledTimes(2)
166+
})
167+
111168
it('computes group context from all deduped matching options', async () => {
112169
const codec = makeCodec()
113170
const transport = makeTransport()

0 commit comments

Comments
 (0)