Skip to content

Commit 253950b

Browse files
authored
feat(openapi): multiple success response support (#490)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for handling multiple success status codes and complex response structures in OpenAPI generation when using detailed output mode. - Introduced a utility function to flatten nested union schemas for better schema processing. - **Bug Fixes** - Improved validation and error messages for invalid or duplicate status codes in detailed output structures. - **Documentation** - Enhanced documentation for input/output structure options and route configuration with clearer explanations and richer examples. - **Tests** - Expanded test coverage for input/output merging, custom status codes, headers, error scenarios, and union schema expansion in encoding/decoding and OpenAPI generation. - **Refactor** - Improved internal validation logic for detailed output structures and encapsulated it in a dedicated method. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3cc45a9 commit 253950b

10 files changed

Lines changed: 383 additions & 81 deletions

File tree

apps/content/docs/openapi/input-output-structure.md

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ oRPC allows you to control the organization of request inputs and response outpu
99

1010
## Input Structure
1111

12-
The `inputStructure` option defines how the incoming request data is structured. You can choose between two modes:
12+
The `inputStructure` option defines how the incoming request data is structured.
1313

14-
- **compact** (default): Merges path parameters with either the query or body data (depending on the HTTP method) into a single object.
15-
- **detailed**: Separates the request into distinct objects for `params`, `query`, `headers`, and `body`.
14+
### Compact Mode (default)
1615

17-
### Compact Mode
16+
Combines path parameters with query or body data (depending on the HTTP method) into a single object.
1817

1918
```ts
2019
const compactMode = os.route({
@@ -29,6 +28,13 @@ const compactMode = os.route({
2928

3029
### Detailed Mode
3130

31+
Provide an object whose fields correspond to each part of the request:
32+
33+
- `params`: Path parameters (`Record<string, string> | undefined`)
34+
- `query`: Query string data (`any`)
35+
- `headers`: Headers (`Record<string, string | string[] | undefined>`)
36+
- `body`: Body data (`any`)
37+
3238
```ts
3339
const detailedMode = os.route({
3440
path: '/ping/{name}',
@@ -43,27 +49,13 @@ const detailedMode = os.route({
4349
}))
4450
```
4551

46-
When using **detailed** mode, the input object adheres to the following structure:
47-
48-
```ts
49-
export type DetailedInput = {
50-
params: Record<string, string> | undefined
51-
query: any
52-
body: any
53-
headers: Record<string, string | string[] | undefined>
54-
}
55-
```
56-
57-
Ensure your input schema matches this structure when detailed mode is enabled.
58-
5952
## Output Structure
6053

61-
The `outputStructure` option determines the format of the response data. There are two modes:
54+
The `outputStructure` option determines the format of the response based on the output data.
6255

63-
- **compact** (default): Returns only the body data directly.
64-
- **detailed**: Returns an object with separate `headers` and `body` fields. The headers you provide are merged into the final HTTP response headers.
56+
### Compact Mode (default)
6557

66-
### Compact Mode
58+
Returns the output data directly as the response body.
6759

6860
```ts
6961
const compactMode = os
@@ -74,6 +66,12 @@ const compactMode = os
7466

7567
### Detailed Mode
7668

69+
Returns an object with these optional properties:
70+
71+
- `status`: The response status (must be in 200-399 range) if not set fallback to `successStatus`.
72+
- `headers`: Custom headers to merge with the response headers (`Record<string, string | string[] | undefined>`).
73+
- `body`: The response body.
74+
7775
```ts
7876
const detailedMode = os
7977
.route({ outputStructure: 'detailed' })
@@ -83,19 +81,34 @@ const detailedMode = os
8381
body: { message: 'Hello, world!' },
8482
}
8583
})
86-
```
8784

88-
When using **detailed** mode, the output object follows this structure:
85+
const multipleStatus = os
86+
.route({ outputStructure: 'detailed' })
87+
.output(z.union([ // for openapi spec generator
88+
z.object({
89+
status: z.literal(201).describe('record created'),
90+
body: z.string()
91+
}),
92+
z.object({
93+
status: z.literal(200).describe('record updated'),
94+
body: z.string()
95+
}),
96+
]))
97+
.handler(async ({ input }) => {
98+
if (something) {
99+
return {
100+
status: 201,
101+
body: 'created',
102+
}
103+
}
89104

90-
```ts
91-
export type DetailedOutput = {
92-
headers: Record<string, string | string[] | undefined>
93-
body: any
94-
}
105+
return {
106+
status: 200,
107+
body: 'updated',
108+
}
109+
})
95110
```
96111

97-
Make sure your handler’s return value matches this structure when using detailed mode.
98-
99112
## Initial Configuration
100113

101114
Customize the initial oRPC input/output structure settings using `.$route`:

packages/contract/src/route.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface Route {
5454

5555
/**
5656
* The status code of the response when the procedure is successful.
57+
* The status code must be in the 200-399 range.
5758
* This option is typically relevant when integrating with OpenAPI.
5859
*
5960
* @see {@link https://orpc.unnoq.com/docs/openapi/routing OpenAPI Routing Docs}
@@ -98,16 +99,18 @@ export interface Route {
9899
* Determines how the response should be structured based on the output.
99100
*
100101
* @option 'compact'
101-
* Includes only the body data, encoded directly in the response.
102+
* The output data is directly returned as the response body.
102103
*
103104
* @option 'detailed'
104-
* Separates the output into `headers` and `body` fields.
105-
* - `headers`: Custom headers to merge with the response headers.
106-
* - `body`: The response data.
105+
* Return an object with optional properties:
106+
* - `status`: The response status (must be in 200-399 range) if not set fallback to `successStatus`.
107+
* - `headers`: Custom headers to merge with the response headers (`Record<string, string | string[] | undefined>`)
108+
* - `body`: The response body.
107109
*
108110
* Example:
109111
* ```ts
110112
* const output = {
113+
* status: 201,
111114
* headers: { 'x-custom-header': 'value' },
112115
* body: { message: 'Hello, world!' },
113116
* };

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,12 @@ describe('standardOpenapiLinkCodecOptions', () => {
334334
status: 201,
335335
}, { context: {}, signal }, ['ping'])
336336

337-
expect((output as any).headers).toEqual({ 'x-custom': 'value' })
337+
expect(output).toEqual({
338+
status: 201,
339+
headers: { 'x-custom': 'value' },
340+
body: deserialize.mock.results[0]!.value,
341+
})
342+
338343
expect((output as any).body).toBe(deserialize.mock.results[0]!.value)
339344

340345
expect(deserialize).toHaveBeenCalledTimes(1)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export class StandardOpenapiLinkCodec<T extends ClientContext> implements Standa
240240
}
241241

242242
return {
243+
status: response.status,
243244
headers: response.headers,
244245
body: deserialized,
245246
}

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

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('standardOpenAPICodec', () => {
1818
describe('.decode', () => {
1919
describe('with compact structure', () => {
2020
it('with GET method', async () => {
21-
serializer.deserialize.mockReturnValueOnce('__deserialized__')
21+
serializer.deserialize.mockReturnValueOnce(undefined)
2222

2323
const url = new URL('http://localhost/api/v1?data=data')
2424
url.searchParams.append('data', JSON.stringify('__data__'))
@@ -29,9 +29,9 @@ describe('standardOpenAPICodec', () => {
2929
body: vi.fn(),
3030
headers: {},
3131
signal: undefined,
32-
}, undefined, ping)
32+
}, { name: 'John Doe' }, ping)
3333

34-
expect(input).toEqual('__deserialized__')
34+
expect(input).toEqual({ name: 'John Doe' })
3535

3636
expect(serializer.deserialize).toHaveBeenCalledOnce()
3737
expect(serializer.deserialize).toHaveBeenCalledWith(url.searchParams)
@@ -55,6 +55,25 @@ describe('standardOpenAPICodec', () => {
5555
expect(serializer.deserialize).toHaveBeenCalledOnce()
5656
expect(serializer.deserialize).toHaveBeenCalledWith(serialized)
5757
})
58+
59+
it('params and body are merged', async () => {
60+
const serialized = '__data__'
61+
62+
serializer.deserialize.mockReturnValueOnce({ v1: 'v1' })
63+
64+
const input = await codec.decode({
65+
method: 'POST',
66+
url: new URL('http://localhost/api/v1?data=data'),
67+
body: vi.fn(async () => serialized),
68+
headers: {},
69+
signal: undefined,
70+
}, { v2: 'v2' }, ping)
71+
72+
expect(input).toEqual({ v1: 'v1', v2: 'v2' })
73+
74+
expect(serializer.deserialize).toHaveBeenCalledOnce()
75+
expect(serializer.deserialize).toHaveBeenCalledWith(serialized)
76+
})
5877
})
5978

6079
describe('with detailed structure', () => {
@@ -124,6 +143,26 @@ describe('standardOpenAPICodec', () => {
124143
expect(serializer.deserialize).toHaveBeenNthCalledWith(1, serialized)
125144
expect(serializer.deserialize).toHaveBeenNthCalledWith(2, url.searchParams)
126145
})
146+
147+
it('can set query', async () => {
148+
const serialized = '__data__'
149+
150+
serializer.deserialize.mockReturnValue('__deserialized__')
151+
const url = new URL('http://localhost/api/v1?data=data')
152+
153+
const input = await codec.decode({
154+
method: 'POST',
155+
url,
156+
body: vi.fn(async () => serialized),
157+
headers: {
158+
'content-type': 'application/json',
159+
},
160+
signal: undefined,
161+
}, { name: 'John Doe' }, procedure) as any
162+
163+
input.query = { name: 'John Doe' }
164+
expect(input.query).toEqual({ name: 'John Doe' })
165+
})
127166
})
128167
})
129168

@@ -152,10 +191,6 @@ describe('standardOpenAPICodec', () => {
152191
},
153192
})
154193

155-
it('throw on invalid output', async () => {
156-
expect(() => codec.encode('__output__', procedure)).toThrowError()
157-
})
158-
159194
it('works', async () => {
160195
serializer.serialize.mockReturnValue('__serialized__')
161196

@@ -175,15 +210,56 @@ describe('standardOpenAPICodec', () => {
175210
body: '__serialized__',
176211
})
177212

213+
expect(serializer.serialize).toHaveBeenCalledTimes(1)
214+
expect(serializer.serialize).toHaveBeenCalledWith('__output__')
215+
})
216+
217+
it('works with empty output', async () => {
218+
serializer.serialize.mockReturnValue('__serialized__')
219+
178220
expect(codec.encode({}, procedure)).toEqual({
179221
status: 298,
180222
headers: {},
181223
body: '__serialized__',
182224
})
183225

184-
expect(serializer.serialize).toHaveBeenCalledTimes(2)
185-
expect(serializer.serialize).toHaveBeenNthCalledWith(1, '__output__')
186-
expect(serializer.serialize).toHaveBeenNthCalledWith(2, undefined)
226+
expect(serializer.serialize).toHaveBeenCalledTimes(1)
227+
expect(serializer.serialize).toHaveBeenCalledWith(undefined)
228+
})
229+
230+
it('works with custom status', async () => {
231+
serializer.serialize.mockReturnValue('__serialized__')
232+
233+
const output = {
234+
status: 201,
235+
body: '__output__',
236+
headers: {
237+
'x-custom-header': 'custom-value',
238+
},
239+
}
240+
const response = codec.encode(output, procedure)
241+
242+
expect(response).toEqual({
243+
status: 201,
244+
headers: {
245+
'x-custom-header': 'custom-value',
246+
},
247+
body: '__serialized__',
248+
})
249+
250+
expect(serializer.serialize).toHaveBeenCalledTimes(1)
251+
expect(serializer.serialize).toHaveBeenCalledWith('__output__')
252+
})
253+
254+
it.each([
255+
'invalid',
256+
{ status: 'invalid' },
257+
{ status: 400 },
258+
{ status: 200.1 },
259+
{ status: 'invalid' },
260+
{ headers: 'invalid' },
261+
])('throw on invalid output: %s', async (output) => {
262+
expect(() => codec.encode(output, procedure)).toThrowError()
187263
})
188264
})
189265
})

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import type { StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
33
import type { AnyProcedure } from '@orpc/server'
44
import type { StandardCodec, StandardParams } from '@orpc/server/standard'
55
import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@orpc/standard-server'
6+
import { isORPCErrorStatus } from '@orpc/client'
67
import { fallbackContractConfig } from '@orpc/contract'
7-
import { isObject } from '@orpc/shared'
8+
import { isObject, stringifyJSON } from '@orpc/shared'
89

910
export class StandardOpenAPICodec implements StandardCodec {
1011
constructor(
@@ -65,15 +66,23 @@ export class StandardOpenAPICodec implements StandardCodec {
6566
}
6667
}
6768

68-
if (!isObject(output)) {
69-
throw new Error(
70-
'Invalid output structure for "detailed" output. Expected format: { body: any, headers?: Record<string, string | string[] | undefined> }',
71-
)
69+
if (!this.#isDetailedOutput(output)) {
70+
throw new Error(`
71+
Invalid "detailed" output structure:
72+
• Expected an object with optional properties:
73+
- status (number 200-399)
74+
- headers (Record<string, string | string[]>)
75+
- body (any)
76+
• No extra keys allowed.
77+
78+
Actual value:
79+
${stringifyJSON(output)}
80+
`)
7281
}
7382

7483
return {
75-
status: successStatus,
76-
headers: output.headers as StandardHeaders ?? {},
84+
status: output.status ?? successStatus,
85+
headers: output.headers ?? {},
7786
body: this.serializer.serialize(output.body),
7887
}
7988
}
@@ -85,4 +94,20 @@ export class StandardOpenAPICodec implements StandardCodec {
8594
body: this.serializer.serialize(error.toJSON(), { outputFormat: 'plain' }),
8695
}
8796
}
97+
98+
#isDetailedOutput(output: unknown): output is { status?: number, body?: unknown, headers?: StandardHeaders } {
99+
if (!isObject(output)) {
100+
return false
101+
}
102+
103+
if (output.headers && !isObject(output.headers)) {
104+
return false
105+
}
106+
107+
if (output.status !== undefined && (typeof output.status !== 'number' || !Number.isInteger(output.status) || isORPCErrorStatus(output.status))) {
108+
return false
109+
}
110+
111+
return true
112+
}
88113
}

0 commit comments

Comments
 (0)