From 4c223de56af9287bd0e8d9e07c0664ba95d1350b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 7 Oct 2026 17:03:21 +0700 Subject: [PATCH] fix(openapi): give path params precedence in compact input In compact input structure, path params were spread before the query or body, so a colliding key from the client replaced the value the router took from the URL. Middleware that authorized against the path param could then hand the handler a different value than the one it checked. Path params now win on collision. When the body cannot be merged (primitive, array, Blob, ...), only the path params are used, matching the OpenAPI generator. Backports #1947 and #1978 from main. --- .../docs/openapi/input-output-structure.md | 2 +- .../adapters/fetch/openapi-handler.test.ts | 19 ++++ .../adapters/standard/openapi-codec.test.ts | 86 +++++++++++++++++++ .../src/adapters/standard/openapi-codec.ts | 18 ++-- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/apps/content/docs/openapi/input-output-structure.md b/apps/content/docs/openapi/input-output-structure.md index 8b5fa2bbd..2a05a8521 100644 --- a/apps/content/docs/openapi/input-output-structure.md +++ b/apps/content/docs/openapi/input-output-structure.md @@ -13,7 +13,7 @@ The `inputStructure` option defines how the incoming request data is structured. ### Compact Mode (default) -Combines path parameters with query or body data (depending on the HTTP method) into a single object. +Combines path parameters with query or body data (depending on the HTTP method) into a single object. Path parameters take precedence over query or body fields with the same name. If the path has parameters and the body cannot be merged (e.g. a primitive, array, or file), only the path parameters are used. Use detailed mode if you also need the body. ```ts const compactMode = os.route({ diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 119b90e7a..e9bfe99ed 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -12,4 +12,23 @@ describe('openAPIHandler', () => { await expect(response?.text()).resolves.toContain('hello') expect(response?.status).toBe(200) }) + + it('does not let query or body override path params in compact input', async () => { + const handler = new OpenAPIHandler({ + get: os.route({ method: 'GET', path: '/posts/{id}' }).handler(({ input }) => input), + update: os.route({ method: 'POST', path: '/posts/{id}' }).handler(({ input }) => input), + }) + + const { response: getResponse } = await handler.handle(new Request('https://example.com/posts/42?id=99&q=hello')) + + await expect(getResponse?.json()).resolves.toEqual({ id: '42', q: 'hello' }) + + const { response: postResponse } = await handler.handle(new Request('https://example.com/posts/24', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: '99', title: 'hello' }), + })) + + await expect(postResponse?.json()).resolves.toEqual({ id: '24', title: 'hello' }) + }) }) diff --git a/packages/openapi/src/adapters/standard/openapi-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-codec.test.ts index f85040977..587771e26 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.test.ts @@ -74,6 +74,92 @@ describe('standardOpenAPICodec', () => { expect(serializer.deserialize).toHaveBeenCalledOnce() expect(serializer.deserialize).toHaveBeenCalledWith(serialized) }) + + it('gives path params precedence over conflicting query params', async () => { + serializer.deserialize.mockReturnValueOnce({ id: '99', q: 'hello' }) + + const input = await codec.decode({ + method: 'GET', + url: new URL('http://localhost/42?id=99&q=hello'), + body: vi.fn(), + headers: {}, + signal: undefined, + }, { id: '42' }, ping) + + expect(input).toEqual({ id: '42', q: 'hello' }) + }) + + it('gives path params precedence over conflicting body properties', async () => { + serializer.deserialize.mockReturnValueOnce({ id: '99', title: 'hello' }) + + const input = await codec.decode({ + method: 'POST', + url: new URL('http://localhost/24'), + body: vi.fn(async () => '__body__'), + headers: {}, + signal: undefined, + }, { id: '24' }, ping) + + expect(input).toEqual({ id: '24', title: 'hello' }) + }) + + it('returns only path params when a primitive body cannot be merged', async () => { + serializer.deserialize.mockReturnValueOnce('raw-body') + + const input = await codec.decode({ + method: 'POST', + url: new URL('http://localhost/24'), + body: vi.fn(async () => '__body__'), + headers: {}, + signal: undefined, + }, { id: '24' }, ping) + + expect(input).toEqual({ id: '24' }) + }) + + it('returns only path params when an array body cannot be merged', async () => { + serializer.deserialize.mockReturnValueOnce(['first', 'second']) + + const input = await codec.decode({ + method: 'POST', + url: new URL('http://localhost/24'), + body: vi.fn(async () => '__body__'), + headers: {}, + signal: undefined, + }, { id: '24' }, ping) + + expect(input).toEqual({ id: '24' }) + }) + + it('returns only path params when a binary body cannot be merged', async () => { + const file = new File(['raw-bytes'], 'other.txt') + serializer.deserialize.mockReturnValueOnce(file) + + const input = await codec.decode({ + method: 'POST', + url: new URL('http://localhost/report.txt'), + body: vi.fn(async () => file), + headers: {}, + signal: undefined, + }, { name: 'report.txt' }, ping) + + expect(input).toEqual({ name: 'report.txt' }) + }) + + it('returns a binary body as-is when there are no path params', async () => { + const blob = new Blob(['raw-bytes']) + serializer.deserialize.mockReturnValueOnce(blob) + + const input = await codec.decode({ + method: 'POST', + url: new URL('http://localhost/submit'), + body: vi.fn(async () => blob), + headers: {}, + signal: undefined, + }, undefined, ping) + + expect(input).toBe(blob) + }) }) describe('with detailed structure', () => { diff --git a/packages/openapi/src/adapters/standard/openapi-codec.ts b/packages/openapi/src/adapters/standard/openapi-codec.ts index 9b719f557..484c84d37 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.ts @@ -42,14 +42,20 @@ export class StandardOpenAPICodec implements StandardCodec { return params } - if (isObject(data)) { - return { - ...params, - ...data, - } + if (!params || Object.keys(params).length < 1) { + return data } - return data + // Non-object data (primitive, array, Blob, ReadableStream, ...) cannot be merged with params. + // Prefer params to stay consistent with the OpenAPI generator, which only describes path params here. + if (!isObject(data)) { + return params + } + + return { + ...data, + ...params, + } } const deserializeSearchParams = () => {