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 = () => {