Skip to content

Commit 5737a7d

Browse files
authored
feat(openapi): custom openapi json serializer (#246)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced the JSON serialization engine to support custom serialization strategies via configurable options. - Updated API handlers to accept these serializer options, allowing for more flexible data handling. - Added a new documentation section for "OpenAPI JSON Serializer" to guide users on customization. - Expanded the list of supported data types for both `OpenAPIHandler` and `RPCHandler` to include **Record (object)** and **Array**. - **Tests** - Added a comprehensive suite of unit tests covering built-in and custom data types, including edge case handling (e.g., `undefined` values), to ensure robust serialization behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5d6030b commit 5737a7d

File tree

10 files changed

+361
-5
lines changed

10 files changed

+361
-5
lines changed

apps/content/.vitepress/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export default defineConfig({
182182
{ text: 'OpenAPI Link', link: '/docs/openapi/client/openapi-link' },
183183
],
184184
},
185+
{
186+
text: 'Advanced',
187+
collapsed: true,
188+
items: [
189+
{ text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' },
190+
],
191+
},
185192
],
186193
'/examples/': [
187194
{ text: 'OpenAI Streaming', link: '/examples/openai-streaming' },
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
title: OpenAPI JSON Serializer
3+
description: Extend or override the standard OpenAPI JSON serializer.
4+
---
5+
6+
# OpenAPI JSON Serializer
7+
8+
This serializer processes JSON payloads for the [OpenAPIHandler](/docs/openapi/openapi-handler) and supports [native data types](/docs/openapi/openapi-handler#supported-data-types).
9+
10+
## Extending Native Data Types
11+
12+
Customize serialization by creating your own `StandardOpenAPICustomJsonSerializer` and adding it to the `customJsonSerializers` option.
13+
14+
1. **Define Your Custom Serializer**
15+
16+
```ts twoslash
17+
import type { StandardOpenAPICustomJsonSerializer } from '@orpc/openapi-client/standard'
18+
19+
export class User {
20+
constructor(
21+
public readonly id: string,
22+
public readonly name: string,
23+
public readonly email: string,
24+
public readonly age: number,
25+
) {}
26+
27+
toJSON() {
28+
return {
29+
id: this.id,
30+
name: this.name,
31+
email: this.email,
32+
age: this.age,
33+
}
34+
}
35+
}
36+
37+
export const userSerializer: StandardOpenAPICustomJsonSerializer = {
38+
condition: data => data instanceof User,
39+
serialize: data => data.toJSON(),
40+
}
41+
```
42+
43+
2. **Use Your Custom Serializer**
44+
45+
```ts twoslash
46+
import type { StandardOpenAPICustomJsonSerializer } from '@orpc/openapi-client/standard'
47+
import { OpenAPIHandler } from '@orpc/openapi/fetch'
48+
import { OpenAPIGenerator } from '@orpc/openapi'
49+
declare const router: Record<never, never>
50+
declare const userSerializer: StandardOpenAPICustomJsonSerializer
51+
// ---cut---
52+
const handler = new OpenAPIHandler(router, {
53+
customJsonSerializers: [userSerializer],
54+
})
55+
56+
const generator = new OpenAPIGenerator({
57+
customJsonSerializers: [userSerializer],
58+
})
59+
```
60+
61+
::: info
62+
It is recommended to add custom serializers to the `OpenAPIGenerator` for consistent serialization in the OpenAPI document.
63+
:::

apps/content/docs/openapi/openapi-handler.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ The `OpenAPIHandler` enables communication with clients over RESTful APIs, adher
2020
- **BigInt** (`BigInt``string`)
2121
- **RegExp** (`RegExp``string`)
2222
- **URL** (`URL``string`)
23+
- **Record (object)**
24+
- **Array**
2325
- **Set** (`Set``array`)
2426
- **Map** (`Map``array`)
2527
- **Blob** (unsupported in `AsyncIteratorObject`)
@@ -30,6 +32,10 @@ The `OpenAPIHandler` enables communication with clients over RESTful APIs, adher
3032
If a payload contains `Blob` or `File` outside the root level, it must use `multipart/form-data`. In such cases, oRPC applies [Bracket Notation](/docs/openapi/bracket-notation) and converts other types to strings (exclude `null` and `undefined` will not be represented).
3133
:::
3234

35+
:::tip
36+
You can extend the list of supported types by [creating a custom serializer](/docs/openapi/advanced/openapi-json-serializer#extending-native-data-types).
37+
:::
38+
3339
## Installation
3440

3541
::: code-group

apps/content/docs/rpc-handler.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@ The `RPCHandler` enables communication with clients over oRPC's proprietary [RPC
2424
- **BigInt**
2525
- **RegExp**
2626
- **URL**
27+
- **Record (object)**
28+
- **Array**
2729
- **Set**
2830
- **Map**
2931
- **Blob** (unsupported in `AsyncIteratorObject`)
3032
- **File** (unsupported in `AsyncIteratorObject`)
3133
- **AsyncIteratorObject** (only at the root level; powers the [Event Iterator](/docs/event-iterator))
3234

35+
:::tip
36+
You can extend the list of supported types by [creating a custom serializer](/docs/advanced/rpc-json-serializer#extending-native-data-types).
37+
:::
38+
3339
## Setup and Integration
3440

3541
```ts
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { StandardOpenAPIJsonSerializer } from './openapi-json-serializer'
2+
3+
type TestCase = {
4+
data: unknown
5+
expected?: unknown
6+
}
7+
8+
enum Test {
9+
A = 1,
10+
B = 2,
11+
C = 'C',
12+
D = 'D',
13+
}
14+
15+
const builtInCases: TestCase[] = [
16+
{
17+
data: Test.B,
18+
expected: Test.B,
19+
},
20+
{
21+
data: 'some-string',
22+
expected: 'some-string',
23+
},
24+
{
25+
data: 123,
26+
expected: 123,
27+
},
28+
{
29+
data: Number.NaN,
30+
expected: null,
31+
},
32+
{
33+
data: true,
34+
expected: true,
35+
},
36+
{
37+
data: false,
38+
expected: false,
39+
},
40+
{
41+
data: null,
42+
expected: null,
43+
},
44+
// {
45+
// data: undefined,
46+
// expected: expect.toSatisfy(v => v === null || v === undefined), CANNOT ASSERT UNDEFINED IN OBJECT?
47+
// },
48+
{
49+
data: new Date('2023-01-01'),
50+
expected: new Date('2023-01-01').toISOString(),
51+
},
52+
{
53+
data: new Date('Invalid'),
54+
expected: null,
55+
},
56+
{
57+
data: 99999999999999999999999999999n,
58+
expected: '99999999999999999999999999999',
59+
},
60+
{
61+
data: /npa|npb/,
62+
expected: '/npa|npb/',
63+
},
64+
{
65+
data: /uic/gi,
66+
expected: '/uic/gi',
67+
},
68+
{
69+
data: new URL('https://unnoq.com'),
70+
expected: new URL('https://unnoq.com').href,
71+
},
72+
{
73+
data: { a: 1, b: 2, c: 3 },
74+
expected: { a: 1, b: 2, c: 3 },
75+
},
76+
{
77+
data: [1, 2, 3],
78+
expected: [1, 2, 3],
79+
},
80+
{
81+
data: new Map([[1, 2], [3, 4]]),
82+
expected: [[1, 2], [3, 4]],
83+
},
84+
{
85+
data: new Set([1, 2, 3]),
86+
expected: [1, 2, 3],
87+
},
88+
{
89+
data: new Blob(['blob'], { type: 'text/plain' }),
90+
expected: expect.toSatisfy((file: any) => {
91+
expect(file).toBeInstanceOf(Blob)
92+
expect(file.type).toBe('text/plain')
93+
expect(file.size).toBe(4)
94+
95+
return true
96+
}),
97+
},
98+
{
99+
data: new File(['"name"'], 'file.json', { type: 'application/json' }),
100+
expected: expect.toSatisfy((file: any) => {
101+
expect(file).toBeInstanceOf(File)
102+
expect(file.name).toBe('file.json')
103+
expect(file.type).toBe('application/json')
104+
expect(file.size).toBe(6)
105+
106+
return true
107+
}),
108+
},
109+
]
110+
111+
class Person {
112+
constructor(
113+
public name: string,
114+
public date: Date,
115+
) { }
116+
117+
toJSON() {
118+
return {
119+
name: this.name,
120+
date: this.date,
121+
}
122+
}
123+
}
124+
125+
class Person2 {
126+
constructor(
127+
public name: string,
128+
public data: any,
129+
) { }
130+
131+
toJSON() {
132+
return {
133+
name: this.name,
134+
data: this.data,
135+
}
136+
}
137+
}
138+
139+
const customSupportedDataTypes: TestCase[] = [
140+
{
141+
data: new Person('unnoq', new Date('2023-01-01')),
142+
expected: { name: 'unnoq', date: '2023-01-01T00:00:00.000Z' },
143+
},
144+
{
145+
data: new Person2('unnoq - 2', [{ nested: new Date('2023-01-02') }, /uic/gi]),
146+
expected: { name: 'unnoq - 2', data: [{ nested: '2023-01-02T00:00:00.000Z' }, '/uic/gi'] },
147+
},
148+
]
149+
150+
describe.each<TestCase>([
151+
...builtInCases,
152+
...customSupportedDataTypes,
153+
])('serialize %p', ({ data, expected = data }) => {
154+
const serializer = new StandardOpenAPIJsonSerializer({
155+
customJsonSerializers: [
156+
{
157+
condition: data => data instanceof Person,
158+
serialize: data => data.toJSON(),
159+
},
160+
{
161+
condition: data => data instanceof Person2,
162+
serialize: data => data.toJSON(),
163+
},
164+
],
165+
})
166+
167+
it('flat', () => {
168+
const [json, hasBlob] = serializer.serialize(data)
169+
170+
expect(json).toEqual(expected)
171+
expect(hasBlob).toBe(data instanceof Blob)
172+
})
173+
174+
it('object', () => {
175+
const [json, hasBlob] = serializer.serialize({ value: data })
176+
177+
expect(json).toEqual({ value: expected })
178+
expect(hasBlob).toBe(data instanceof Blob)
179+
})
180+
181+
it('array', () => {
182+
const [json, hasBlob] = serializer.serialize([data])
183+
184+
expect(json).toEqual([expected])
185+
expect(hasBlob).toBe(data instanceof Blob)
186+
})
187+
188+
it('set', () => {
189+
const [json, hasBlob] = serializer.serialize(new Set([data]))
190+
191+
expect(json).toEqual([expected])
192+
expect(hasBlob).toBe(data instanceof Blob)
193+
})
194+
195+
it('map', () => {
196+
const [json, hasBlob] = serializer.serialize(new Map([[data, data]]))
197+
198+
expect(json).toEqual([[expected, expected]])
199+
expect(hasBlob).toBe(data instanceof Blob)
200+
})
201+
202+
it('complex', () => {
203+
const [json, hasBlob] = serializer.serialize({
204+
'date': new Date('2023-01-01'),
205+
'regexp': /uic/gi,
206+
'url': new URL('https://unnoq.com'),
207+
'!@#$%^^&()[]>?<~_<:"~+!_': data,
208+
'list': [data],
209+
'map': new Map([[data, data]]),
210+
'set': new Set([data]),
211+
'nested': {
212+
nested: data,
213+
},
214+
})
215+
216+
expect(json).toEqual({
217+
'date': new Date('2023-01-01').toISOString(),
218+
'regexp': (/uic/gi).toString(),
219+
'url': new URL('https://unnoq.com').href,
220+
'!@#$%^^&()[]>?<~_<:"~+!_': expected,
221+
'list': [expected],
222+
'map': [[expected, expected]],
223+
'set': [expected],
224+
'nested': {
225+
nested: expected,
226+
},
227+
})
228+
229+
expect(hasBlob).toBe(data instanceof Blob)
230+
})
231+
})
232+
233+
describe('serialize undefined', () => {
234+
const serializer = new StandardOpenAPIJsonSerializer()
235+
236+
it('in object', () => {
237+
const [json, hasBlob] = serializer.serialize({ value: undefined })
238+
239+
expect(json).toEqual({ value: undefined })
240+
expect(hasBlob).toBe(false)
241+
})
242+
243+
it('in array', () => {
244+
const [json, hasBlob] = serializer.serialize([undefined])
245+
246+
expect(json).toEqual([null])
247+
expect(hasBlob).toBe(false)
248+
})
249+
})

packages/openapi-client/src/adapters/standard/openapi-json-serializer.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,31 @@ import { isObject } from '@orpc/shared'
22

33
export type StandardOpenAPIJsonSerialized = [json: unknown, hasBlob: boolean]
44

5+
export interface StandardOpenAPICustomJsonSerializer {
6+
condition(data: unknown): boolean
7+
serialize(data: any): unknown
8+
}
9+
10+
export interface StandardOpenAPIJsonSerializerOptions {
11+
customJsonSerializers?: readonly StandardOpenAPICustomJsonSerializer[]
12+
}
13+
514
export class StandardOpenAPIJsonSerializer {
15+
private readonly customSerializers: readonly StandardOpenAPICustomJsonSerializer[]
16+
17+
constructor(options: StandardOpenAPIJsonSerializerOptions = {}) {
18+
this.customSerializers = options.customJsonSerializers ?? []
19+
}
20+
621
serialize(data: unknown, hasBlobRef: { value: boolean } = { value: false }): StandardOpenAPIJsonSerialized {
22+
for (const custom of this.customSerializers) {
23+
if (custom.condition(data)) {
24+
const result = this.serialize(custom.serialize(data), hasBlobRef)
25+
26+
return result
27+
}
28+
}
29+
730
if (data instanceof Blob) {
831
hasBlobRef.value = true
932
return [data, hasBlobRef.value]

0 commit comments

Comments
 (0)