Skip to content

Commit 5b554ff

Browse files
dinwwwhKingSora
andauthored
fix(openapi): serialize headers in detailed input/output structures (#1743)
Resolves #1739 Header values in `detailed` input/output structures are now serialized the same way query params and body values are, instead of being strictly validated as `string | string[]` upfront. This lets schemas accept non-string header values (numbers, dates, arrays, objects, ...) that are coerced during the transform phase, consistent with how `query` is handled. Co-authored-by: Rene Haas <king-sora@hotmail.de> Co-authored-by: Rene Haas <king-sora@hotmail.de>
1 parent c27eef6 commit 5b554ff

7 files changed

Lines changed: 211 additions & 22 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './openapi-handler-codec'
22
export * from './openapi-link-codec'
33
export * from './openapi-matcher'
4+
export * from './utils'

packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -692,13 +692,8 @@ describe('openAPIHandlerCodec', () => {
692692
})
693693

694694
it('uses explicit status and headers from output', () => {
695-
const serializer = {
696-
serialize: vi.fn().mockReturnValueOnce('__serialized_body__'),
697-
deserialize: vi.fn(),
698-
} as any
699-
700695
const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn())
701-
const codec = new OpenAPIHandlerCodec(procedure, { serializer })
696+
const codec = new OpenAPIHandlerCodec(procedure)
702697

703698
const response = codec.encodeOutput({
704699
status: 202,
@@ -709,15 +704,76 @@ describe('openAPIHandlerCodec', () => {
709704
expect(response).toEqual({
710705
status: 202,
711706
headers: { 'x-custom': 'value' },
712-
body: '__serialized_body__',
707+
body: { ok: true },
713708
})
714709
})
715710

711+
it('accepts informational and redirect statuses since any status below 400 is success', () => {
712+
const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn())
713+
const codec = new OpenAPIHandlerCodec(procedure)
714+
715+
const response = codec.encodeOutput({
716+
status: 302,
717+
headers: { location: 'https://example.com' },
718+
}, procedure, [])
719+
720+
expect(response).toEqual({
721+
status: 302,
722+
headers: { location: 'https://example.com' },
723+
body: undefined,
724+
})
725+
})
726+
727+
it('serializes non-string header values', () => {
728+
const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn())
729+
const codec = new OpenAPIHandlerCodec(procedure)
730+
731+
const response = codec.encodeOutput({
732+
headers: {
733+
'x-string': 'value',
734+
'x-number': 42,
735+
'x-boolean': true,
736+
'x-date': new Date('2020-01-02T03:04:05.000Z'),
737+
'x-array': ['a', 1, null, undefined],
738+
'x-null': null,
739+
'x-undefined': undefined,
740+
},
741+
body: undefined,
742+
}, procedure, []) as any
743+
744+
expect(response.headers).toEqual({
745+
'x-string': 'value',
746+
'x-number': '42',
747+
'x-boolean': 'true',
748+
'x-date': '2020-01-02T03:04:05.000Z',
749+
'x-array': ['a', '1'],
750+
})
751+
expect(Object.keys(response.headers)).not.toContain('x-null')
752+
expect(Object.keys(response.headers)).not.toContain('x-undefined')
753+
})
754+
755+
it('prevents prototype injection via header keys', () => {
756+
const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn())
757+
const codec = new OpenAPIHandlerCodec(procedure)
758+
759+
const response = codec.encodeOutput({
760+
headers: JSON.parse('{"__proto__": "injected", "constructor": "c", "x-safe": "ok"}'),
761+
}, procedure, []) as any
762+
763+
expect(response.headers['x-safe']).toBe('ok')
764+
765+
// `__proto__` and `constructor` become plain own properties, not prototype-chain mutations
766+
expect(Object.getOwnPropertyDescriptor(response.headers, '__proto__')?.value).toBe('injected')
767+
expect(Object.getOwnPropertyDescriptor(response.headers, 'constructor')?.value).toBe('c')
768+
})
769+
716770
it.each([
717771
['non-object output', '__invalid__'],
718-
['status outside the allowed range', { status: 500 }],
772+
['status of 400 or above', { status: 400 }],
773+
['non-integer status', { status: 250.5 }],
774+
['non-number status', { status: '200' }],
719775
['extra keys', { body: 'ok', extra: true }],
720-
['invalid headers', { headers: { 'x-invalid': 123 } }],
776+
['non-object headers', { headers: 'invalid' }],
721777
])('throws for invalid output: %s', (_, output) => {
722778
const procedure = os.meta(openapi({ outputStructure: 'detailed' })).handler(vi.fn())
723779
const codec = new OpenAPIHandlerCodec(procedure)

packages/openapi/src/adapters/standard/openapi-handler-codec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { AnyORPCError } from '@orpc/client'
22
import type { AnyProcedure, AnyRouter, Context } from '@orpc/server'
33
import type { StandardHandlerCodec, StandardHandlerCodecResolvedProcedure, StandardHandlerHandleOptions } from '@orpc/server/standard'
44
import type { Promisable } from '@orpc/shared'
5-
import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@standardserver/core'
5+
import type { StandardLazyRequest, StandardResponse } from '@standardserver/core'
66
import type { OpenAPIMeta } from '../../meta'
77
import type { OpenAPIMatcherOptions } from './openapi-matcher'
88
import { COMMON_ERROR_STATUS_MAP } from '@orpc/client'
99
import { DEFAULT_ERROR_STATUS, DEFAULT_SUCCESS_STATUS } from '@orpc/server'
1010
import { isPlainObject, isTypescriptObject, NullProtoObj, parseEmptyableJSON, stringifyJSON } from '@orpc/shared'
11-
import { isStandardHeaders, parseStandardUrl } from '@standardserver/core'
11+
import { parseStandardUrl } from '@standardserver/core'
1212
import {
1313
DEFAULT_OPENAPI_INPUT_STRUCTURE,
1414
DEFAULT_OPENAPI_OUTPUT_STRUCTURE,
@@ -17,6 +17,7 @@ import { getOpenAPIMeta } from '../../meta'
1717
import { OpenAPISerializer } from '../../openapi-serializer'
1818
import { isBodylessMethod } from '../../utils'
1919
import { OpenAPIMatcher } from './openapi-matcher'
20+
import { serializeHeaders } from './utils'
2021

2122
export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
2223
/**
@@ -118,8 +119,8 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
118119
throw new TypeError(`
119120
Invalid "detailed" output structure returned by procedure (${path.join('.')}):
120121
• Expected an object with optional properties:
121-
- status (number 200-399)
122-
- headers (Record<string, string | string[] | undefined>)
122+
- status (number <400)
123+
- headers (object)
123124
- body (any)
124125
• No extra keys allowed.
125126
@@ -130,7 +131,7 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
130131

131132
return {
132133
status: output.status ?? successStatus,
133-
headers: output.headers ?? {},
134+
headers: output.headers !== undefined ? serializeHeaders(output.headers, this.serializer) : {},
134135
body: this.serializer.serialize(output.body),
135136
}
136137
}
@@ -274,7 +275,7 @@ export class OpenAPIHandlerCodec<T extends Context> extends OpenAPIHandlerCodecC
274275
}
275276
}
276277

277-
function isValidDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: StandardHeaders } {
278+
function isValidDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: object } {
278279
if (!isTypescriptObject(output)) {
279280
return false
280281
}
@@ -286,13 +287,12 @@ function isValidDetailedOutput(output: unknown): output is { status?: number, bo
286287
if (output.status !== undefined && (
287288
typeof output.status !== 'number'
288289
|| !Number.isInteger(output.status)
289-
|| output.status < 200
290290
|| output.status > 399
291291
)) {
292292
return false
293293
}
294294

295-
if (output.headers !== undefined && !isStandardHeaders(output.headers)) {
295+
if (output.headers !== undefined && !isTypescriptObject(output.headers)) {
296296
return false
297297
}
298298

packages/openapi/src/adapters/standard/openapi-link-codec.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,38 @@ describe('openAPILinkCodec', () => {
180180
expect(request.headers).toEqual({ 'x-base': 'yes' })
181181
})
182182

183+
it('serializes non-string header values before merging', async () => {
184+
const codec = new OpenAPILinkCodec({
185+
ping: oc.meta(openapi({ inputStructure: 'detailed' })),
186+
}, {
187+
url: '/api',
188+
headers: { 'x-multi': 'base' },
189+
serializer,
190+
})
191+
192+
const request = await codec.encodeInput({
193+
headers: {
194+
'x-number': 42,
195+
'x-boolean': false,
196+
'x-date': new Date('2020-01-02T03:04:05.000Z'),
197+
'x-array': ['a', 1, null, undefined],
198+
'x-multi': ['b', 'c'],
199+
'x-null': null,
200+
'x-undefined': undefined,
201+
},
202+
}, ['ping'], { context: {} })
203+
204+
expect(request.headers).toEqual({
205+
'x-number': '42',
206+
'x-boolean': 'false',
207+
'x-date': '2020-01-02T03:04:05.000Z',
208+
'x-array': ['a', '1'],
209+
'x-multi': ['base', 'b', 'c'],
210+
})
211+
expect(Object.keys(request.headers)).not.toContain('x-null')
212+
expect(Object.keys(request.headers)).not.toContain('x-undefined')
213+
})
214+
183215
it('omits the body for GET requests while still serializing the query', async () => {
184216
const codec = new OpenAPILinkCodec({
185217
search: oc.meta(openapi({

packages/openapi/src/adapters/standard/openapi-link-codec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createORPCErrorFromJson, isORPCErrorJson, ORPCError } from '@orpc/clien
88
import { getRouterContract, ProcedureContract } from '@orpc/contract'
99
import { unlazy } from '@orpc/server'
1010
import { isTypescriptObject, mergeHttpPath, pathToHttpPath, stringifyJSON, value } from '@orpc/shared'
11-
import { isStandardHeaders, mergeStandardHeaders, parseStandardUrl } from '@standardserver/core'
11+
import { mergeStandardHeaders, parseStandardUrl } from '@standardserver/core'
1212
import { toStandardHeaders } from '@standardserver/fetch'
1313
import {
1414
DEFAULT_OPENAPI_INPUT_STRUCTURE,
@@ -18,6 +18,7 @@ import {
1818
import { getOpenAPIMeta } from '../../meta'
1919
import { OpenAPISerializer } from '../../openapi-serializer'
2020
import { getDynamicPathParams, isBodylessMethod } from '../../utils'
21+
import { serializeHeaders } from './utils'
2122

2223
export class OpenAPILinkCodecError extends TypeError {}
2324

@@ -143,7 +144,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
143144
• Expected an object or undefined with optional properties:
144145
- params (object, required when the path has dynamic params)
145146
- query (object)
146-
- headers (Record<string, string | string[] | undefined>)
147+
- headers (object)
147148
- body (any)
148149
149150
Actual value:
@@ -167,7 +168,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
167168
}
168169

169170
if (input?.headers) {
170-
headers = mergeStandardHeaders(headers, input.headers)
171+
headers = mergeStandardHeaders(headers, serializeHeaders(input.headers, this.serializer))
171172
}
172173

173174
pathname = `${basePathname.replace(END_SLASH_REGEX, '')}${pathname}` as `/${string}`
@@ -471,7 +472,7 @@ function toResolvedStandardHeaders(headers: Headers | StandardHeaders): Standard
471472

472473
function isValidDetailedInput(
473474
input: unknown,
474-
): input is undefined | { params?: Record<string, unknown>, query?: Record<string, unknown>, headers?: StandardHeaders, body?: unknown } {
475+
): input is undefined | { params?: Record<string, unknown>, query?: Record<string, unknown>, headers?: object, body?: unknown } {
475476
if (!isTypescriptObject(input)) {
476477
return input === undefined
477478
}
@@ -484,7 +485,7 @@ function isValidDetailedInput(
484485
return false
485486
}
486487

487-
if (input.headers !== undefined && !isStandardHeaders(input.headers)) {
488+
if (input.headers !== undefined && !isTypescriptObject(input.headers)) {
488489
return false
489490
}
490491

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { OpenAPISerializer } from '../../openapi-serializer'
2+
import { serializeHeaders } from './utils'
3+
4+
const serializer = new OpenAPISerializer()
5+
6+
describe('serializeHeaders', () => {
7+
it('keeps string and string[] values as-is', () => {
8+
expect(serializeHeaders({
9+
'x-string': 'value',
10+
'x-array': ['a', 'b'],
11+
}, serializer)).toEqual({
12+
'x-string': 'value',
13+
'x-array': ['a', 'b'],
14+
})
15+
})
16+
17+
it('serializes non-string values into strings', () => {
18+
expect(serializeHeaders({
19+
'x-number': 42,
20+
'x-boolean': true,
21+
'x-date': new Date('2020-01-02T03:04:05.000Z'),
22+
'x-array': ['a', 1, false, new Date('2020-01-02T03:04:05.000Z')],
23+
}, serializer)).toEqual({
24+
'x-number': '42',
25+
'x-boolean': 'true',
26+
'x-date': '2020-01-02T03:04:05.000Z',
27+
'x-array': ['a', '1', 'false', '2020-01-02T03:04:05.000Z'],
28+
})
29+
})
30+
31+
it('serializes objects as comma-delimited key,value pairs', () => {
32+
expect(serializeHeaders({
33+
'x-object': { enabled: true, count: 2 },
34+
'x-object-nested-date': { at: new Date('2020-01-02T03:04:05.000Z') },
35+
'x-object-skip-nullish': { keep: 'yes', skip: null, omit: undefined },
36+
}, serializer)).toEqual({
37+
'x-object': 'enabled,true,count,2',
38+
'x-object-nested-date': 'at,2020-01-02T03:04:05.000Z',
39+
'x-object-skip-nullish': 'keep,yes',
40+
})
41+
})
42+
43+
it('drops undefined and null values, including array items', () => {
44+
const serialized = serializeHeaders({
45+
'x-null': null,
46+
'x-undefined': undefined,
47+
'x-array': ['keep', null, undefined],
48+
}, serializer)
49+
50+
expect(serialized).toEqual({ 'x-array': ['keep'] })
51+
expect(Object.keys(serialized)).toEqual(['x-array'])
52+
})
53+
54+
it('prevents prototype injection via header keys', () => {
55+
const serialized = serializeHeaders(JSON.parse(
56+
'{"__proto__": { "polluted": "yes" }, "constructor": "c", "toString": "t"}',
57+
), serializer)
58+
59+
expect(({} as any).polluted).toBeUndefined()
60+
61+
// dangerous keys become plain own properties, not prototype-chain mutations
62+
expect(Object.getOwnPropertyDescriptor(serialized, '__proto__')?.value).toBe('polluted,yes')
63+
expect(Object.getOwnPropertyDescriptor(serialized, 'constructor')?.value).toBe('c')
64+
expect(Object.getOwnPropertyDescriptor(serialized, 'toString')?.value).toBe('t')
65+
})
66+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { StandardHeaders } from '@standardserver/core'
2+
import type { OpenAPISerializer } from '../../openapi-serializer'
3+
import { isTypescriptObject, NullProtoObj } from '@orpc/shared'
4+
5+
export function serializeHeaders(
6+
headers: object,
7+
serializer: Pick<OpenAPISerializer, 'serialize'>,
8+
): StandardHeaders {
9+
const result = new NullProtoObj<Record<string, string | string[]>>()
10+
11+
for (const [key, value] of Object.entries(headers)) {
12+
const serialized = serializer.serialize(value)
13+
14+
if (Array.isArray(serialized)) {
15+
result[key] = serialized
16+
.filter(item => item !== undefined && item !== null)
17+
.map(String)
18+
}
19+
20+
else if (isTypescriptObject(serialized)) {
21+
result[key] = Object.entries(serialized)
22+
.filter(([, val]) => val !== undefined && val !== null)
23+
.map(([key, val]) => `${String(key)},${String(val)}`)
24+
.join(',')
25+
}
26+
27+
else if (serialized !== undefined && serialized !== null) {
28+
result[key] = String(serialized)
29+
}
30+
}
31+
32+
return result
33+
}

0 commit comments

Comments
 (0)