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/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'], // ... }), ], 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/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/client/src/plugins/batch.test.ts b/packages/client/src/plugins/batch.test.ts index 706541549..344d2bfd1 100644 --- a/packages/client/src/plugins/batch.test.ts +++ b/packages/client/src/plugins/batch.test.ts @@ -451,14 +451,14 @@ describe('batchLinkPlugin', () => { expect(subResponse1.headers['x-from-batch-response']).toBeUndefined() }) - 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}`, @@ -484,19 +484,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 0dfdf581b..de0bf00b0 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/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 { 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), 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..51338a490 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -78,7 +78,7 @@ export interface OpenAPIGeneratorGenerateOptions { } /** - * Generates an OpenAPI 3.1 document 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} @@ -121,6 +121,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..c7990ad9c 100644 --- a/packages/openapi/src/types.test-d.ts +++ b/packages/openapi/src/types.test-d.ts @@ -1,10 +1,19 @@ 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 } 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['/search']['post']>() + }) +}) + describe('JsonifiedValue', () => { it('flat', () => { expectTypeOf>().toEqualTypeOf() diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 8cd9a9528..6ecb2b9d1 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -3,9 +3,21 @@ 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 +/** + * 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?: undefined | OpenAPIV3_1.PathsObject & { + [path: `/${string}`]: OpenAPIV3_1.PathItemObject & { + query?: OpenAPIOperationObject | undefined + } + } +} + export type JsonifiedValue = T extends string ? T : T extends number ? T diff --git a/packages/server/src/plugins/batch.test.ts b/packages/server/src/plugins/batch.test.ts index d9827cf6a..3e157ed3a 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), }) @@ -153,6 +153,7 @@ describe('batchHandlerPlugin', () => { })) expect(response!.status).toBe(400) + expect(await response!.text()).toContain('Invalid batch request body') expect(handlerFn).toHaveBeenCalledTimes(0) }) @@ -344,6 +345,40 @@ 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() + + 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('sub-request headers', () => { function createHeaderCapturingHandler() { const seenHeaders: Record[] = [] diff --git a/packages/server/src/plugins/batch.ts b/packages/server/src/plugins/batch.ts index 94e1548ed..95183f1c9 100644 --- a/packages/server/src/plugins/batch.ts +++ b/packages/server/src/plugins/batch.ts @@ -148,24 +148,12 @@ 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 { 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' }, @@ -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/packages/server/src/plugins/cors.test.ts b/packages/server/src/plugins/cors.test.ts index 513294e1e..53553d701 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 | undefined) => 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 11d51919a..c5ce6af50 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 = { 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..429a1eb08 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: ['GET', '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' })) + }) +})