Skip to content

Commit a2fc015

Browse files
authored
feat(openapi): createJsonifiedRouterClient (#449)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new function for creating JSON-serialized router clients compatible with OpenAPI-based RPC systems. - Expanded the public API to include new router client utilities. - **Documentation** - Added a detailed guide explaining how to use the new JSON-serialized router client with code examples and usage notes. - **Tests** - Added comprehensive tests to verify correct serialization, deserialization, and error handling for the new JSON-serialized router client. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7b73a06 commit a2fc015

File tree

4 files changed

+161
-0
lines changed

4 files changed

+161
-0
lines changed

apps/content/docs/best-practices/optimize-ssr.md

+52
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,58 @@ globalThis.$client = createRouterClient(router, {
8585

8686
:::
8787

88+
::: details `OpenAPILink` support?
89+
When you use [OpenAPILink](/docs/openapi/client/openapi-link), its `JsonifiedClient` turns native values (like Date or URL) into plain JSON, so your client types no longer match the output of `createRouterClient`. To fix this, oRPC offers `createJsonifiedRouterClient`, which builds a router client that matches the output of OpenAPILink.
90+
91+
::: code-group
92+
93+
```ts [lib/orpc.ts]
94+
import type { RouterClient } from '@orpc/server'
95+
import type { JsonifiedClient } from '@orpc/openapi-client'
96+
import { OpenAPILink } from '@orpc/openapi-client/fetch'
97+
import { createORPCClient } from '@orpc/client'
98+
99+
declare global {
100+
var $client: JsonifiedClient<RouterClient<typeof router>> | undefined
101+
}
102+
103+
const link = new OpenAPILink({
104+
url: () => {
105+
if (typeof window === 'undefined') {
106+
throw new Error('OpenAPILink is not allowed on the server side.')
107+
}
108+
109+
return new URL('/api', window.location.href)
110+
},
111+
})
112+
113+
/**
114+
* Fallback to client-side client if server-side client is not available.
115+
*/
116+
export const client: JsonifiedClient<RouterClient<typeof router>> = globalThis.$client ?? createORPCClient(link)
117+
```
118+
119+
```ts [lib/orpc.server.ts]
120+
'server only'
121+
122+
import { createJsonifiedRouterClient } from '@orpc/openapi-client'
123+
124+
globalThis.$client = createJsonifiedRouterClient(router, {
125+
/**
126+
* Provide initial context if needed.
127+
*
128+
* Because this client instance is shared across all requests,
129+
* only include context that's safe to reuse globally.
130+
* For per-request context, use middleware context or pass a function as the initial context.
131+
*/
132+
context: async () => ({
133+
headers: await headers(),
134+
}),
135+
})
136+
```
137+
138+
:::
139+
88140
Finally, import `lib/orpc.server.ts` before anything else and on the **server only**. For example, in Next.js add it to `app/layout.tsx`:
89141

90142
```ts

packages/openapi/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './openapi'
44
export * from './openapi-custom'
55
export * from './openapi-generator'
66
export * from './openapi-utils'
7+
export * from './router-client'
78
export * from './schema'
89
export * from './schema-converter'
910
export * from './schema-utils'
+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ORPCError, os } from '@orpc/server'
2+
import { createJsonifiedRouterClient } from './router-client'
3+
4+
describe('createJsonifiedRouterClient', () => {
5+
const date = new Date()
6+
const file = new File(['foo'], 'some-name.pdf', { type: 'application/pdf' })
7+
8+
const router = os.router({
9+
date: os.handler(({ input }) => ({ date, nested: { date }, input })),
10+
file: os.handler(({ input }) => ({ file, nested: { file }, input })),
11+
error: os.handler(() => {
12+
throw new Error('error')
13+
}),
14+
orpc_error: os.handler(() => {
15+
throw new ORPCError('BAD_REQUEST', {
16+
message: 'orpc error',
17+
data: { date, nested: { file } },
18+
})
19+
}),
20+
})
21+
22+
it('on success', async () => {
23+
const client = createJsonifiedRouterClient(router)
24+
25+
await expect(client.date('now')).resolves.toEqual({ date: date.toISOString(), nested: { date: date.toISOString() }, input: 'now' })
26+
27+
const { file, nested, input } = await client.file('file')
28+
29+
expect(file).toBeInstanceOf(File)
30+
expect(file.name).toBe('some-name.pdf')
31+
expect(file.type).toBe('application/pdf')
32+
expect(await file.text()).toBe('foo')
33+
34+
expect(nested).toEqual({ file })
35+
expect(input).toBe('file')
36+
})
37+
38+
it('on error', async () => {
39+
const client = createJsonifiedRouterClient(router)
40+
41+
await expect(client.error()).rejects.toSatisfy((e) => {
42+
expect(e).toBeInstanceOf(Error)
43+
expect(e.message).toBe('error')
44+
expect(e.cause).toBeUndefined()
45+
46+
return true
47+
})
48+
49+
await expect(client.orpc_error()).rejects.toSatisfy((e) => {
50+
expect(e).toBeInstanceOf(ORPCError)
51+
expect(e.code).toBe('BAD_REQUEST')
52+
expect(e.status).toBe(400)
53+
expect(e.message).toBe('orpc error')
54+
expect(e.data).toEqual({ date: date.toISOString(), nested: { file } })
55+
56+
return true
57+
})
58+
})
59+
})

packages/openapi/src/router-client.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { JsonifiedClient } from '@orpc/openapi-client'
2+
import type { AnyRouter, ClientContext, CreateProcedureClientOptions, ErrorMap, InferRouterInitialContext, Lazyable, Meta, RouterClient, Schema } from '@orpc/server'
3+
import type { MaybeOptionalOptions } from '@orpc/shared'
4+
import { createORPCErrorFromJson } from '@orpc/client'
5+
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
6+
import { createRouterClient, ORPCError } from '@orpc/server'
7+
import { resolveMaybeOptionalOptions } from '@orpc/shared'
8+
9+
export function createJsonifiedRouterClient<T extends AnyRouter, TClientContext extends ClientContext>(
10+
router: Lazyable<T | undefined>,
11+
...rest: MaybeOptionalOptions<
12+
CreateProcedureClientOptions<
13+
InferRouterInitialContext<T>,
14+
Schema<unknown, unknown>,
15+
ErrorMap,
16+
Meta,
17+
TClientContext
18+
>
19+
>
20+
): JsonifiedClient<RouterClient<T, TClientContext>> {
21+
const options = resolveMaybeOptionalOptions(rest)
22+
23+
const serializer = new StandardOpenAPISerializer(new StandardOpenAPIJsonSerializer(), new StandardBracketNotationSerializer())
24+
25+
options.interceptors ??= []
26+
options.interceptors.unshift(async (options) => {
27+
try {
28+
return serializer.deserialize(
29+
serializer.serialize(
30+
await options.next(),
31+
),
32+
)
33+
}
34+
catch (e) {
35+
if (e instanceof ORPCError) {
36+
throw createORPCErrorFromJson(serializer.deserialize(
37+
serializer.serialize(
38+
e.toJSON(),
39+
{ outputFormat: 'plain' },
40+
),
41+
) as any)
42+
}
43+
44+
throw e
45+
}
46+
})
47+
48+
return createRouterClient(router, options) as any
49+
}

0 commit comments

Comments
 (0)