Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion apps/content/docs/openapi/input-output-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 19 additions & 0 deletions packages/openapi/src/adapters/fetch/openapi-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
})
86 changes: 86 additions & 0 deletions packages/openapi/src/adapters/standard/openapi-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
18 changes: 12 additions & 6 deletions packages/openapi/src/adapters/standard/openapi-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
Loading