Skip to content

Commit a246703

Browse files
authored
feat(openapi): OpenAPILink (#303)
This is a simple OpenAPI without special feature, can come with limitations for non-jsonable request/response Closes: #163 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Documentation** - Enhanced the installation and setup guide for OpenAPILink with clear multi-package manager instructions. - Added detailed sections covering limitations, CORS policy requirements, client context usage, and keep-alive behavior. - Introduced new sections for OpenAPILink functionality and serialization options. - **New Features** - Introduced new package dependencies to expand OpenAPI client capabilities and improve modularity. - Added new exports for the fetch module and enhanced serialization options in the OpenAPI client. - **Bug Fixes** - Refined error handling with updated status code validations, ensuring more consistent response behavior. - Improved header merging logic to handle various scenarios effectively. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ea0903c commit a246703

27 files changed

+1186
-85
lines changed

apps/content/docs/openapi/client/openapi-link.md

+140-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,145 @@ description: Details on using OpenAPILink in oRPC clients.
77

88
OpenAPILink enables communication with an [OpenAPIHandler](/docs/openapi/openapi-handler) on your server using HTTP/Fetch.
99

10+
## Installation
11+
12+
::: code-group
13+
14+
```sh [npm]
15+
npm install @orpc/openapi-client@latest
16+
```
17+
18+
```sh [yarn]
19+
yarn add @orpc/openapi-client@latest
20+
```
21+
22+
```sh [pnpm]
23+
pnpm add @orpc/openapi-client@latest
24+
```
25+
26+
```sh [bun]
27+
bun add @orpc/openapi-client@latest
28+
```
29+
30+
```sh [deno]
31+
deno install npm:@orpc/openapi-client@latest
32+
```
33+
34+
:::
35+
36+
## Setup
37+
38+
To use `OpenAPILink`, you must have a [contract router](/docs/contract-first/define-contract#contract-router) and a server configured with an [OpenAPIHandler](/docs/openapi/openapi-handler).
39+
40+
::: info
41+
A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). You can also unlazy a router using the [unlazyRouter](/docs/advanced/mocking#using-the-implementer) utility.
42+
:::
43+
44+
```ts twoslash
45+
import { contract } from './shared/planet'
46+
// ---cut---
47+
import type { JsonifiedClient } from '@orpc/openapi-client'
48+
import type { ContractRouterClient } from '@orpc/contract'
49+
import { createORPCClient } from '@orpc/client'
50+
import { OpenAPILink } from '@orpc/openapi-client/fetch'
51+
52+
const link = new OpenAPILink(contract, {
53+
url: 'http://localhost:3000/api',
54+
headers: () => ({
55+
'x-api-key': 'my-api-key',
56+
}),
57+
// fetch: <-- polyfill fetch if needed
58+
})
59+
60+
const client: JsonifiedClient<ContractRouterClient<typeof contract>> = createORPCClient(link)
61+
```
62+
63+
:::warning
64+
Wrap your client with `JsonifiedClient` to ensure it accurately reflects the server responses.
65+
:::
66+
67+
## Limitations
68+
69+
Unlike [RPCLink](/docs/client/rpc-link), `OpenAPILink` has some constraints:
70+
71+
- Payloads containing a `Blob` or `File` (outside the root level) must use `multipart/form-data` and serialized using [Bracket Notation](/docs/openapi/bracket-notation).
72+
- For `GET` requests, the payload must be sent as `URLSearchParams` and serialized using [Bracket Notation](/docs/openapi/bracket-notation).
73+
74+
:::warning
75+
In these cases, both the request and response are subject to the limitations of [Bracket Notation Limitations](/docs/openapi/bracket-notation#limitations). Additionally, oRPC converts data to strings (exclude `null` and `undefined` will not be represented).
76+
:::
77+
78+
## CORS policy
79+
80+
`OpenAPILink` requires access to the `Content-Disposition` to distinguish file responses from other responses whe file has a common MIME type like `application/json`, `plain/text`, etc. To enable this, include `Content-Disposition` in your CORS policy's `Access-Control-Expose-Headers`:
81+
82+
```ts
83+
const handler = new OpenAPIHandler(router, {
84+
plugins: [
85+
new CORSPlugin({
86+
exposeHeaders: ['Content-Disposition'],
87+
}),
88+
],
89+
})
90+
```
91+
92+
## Using Client Context
93+
94+
Client context lets you pass extra information when calling procedures and dynamically modify RPCLink’s behavior.
95+
96+
```ts twoslash
97+
import { contract } from './shared/planet'
98+
// ---cut---
99+
import type { JsonifiedClient } from '@orpc/openapi-client'
100+
import type { ContractRouterClient } from '@orpc/contract'
101+
import { createORPCClient } from '@orpc/client'
102+
import { OpenAPILink } from '@orpc/openapi-client/fetch'
103+
104+
interface ClientContext {
105+
something?: string
106+
}
107+
108+
const link = new OpenAPILink<ClientContext>(contract, {
109+
url: 'http://localhost:3000/api',
110+
headers: async ({ context }) => ({
111+
'x-api-key': context?.something ?? ''
112+
})
113+
})
114+
115+
const client: JsonifiedClient<ContractRouterClient<typeof contract, ClientContext>> = createORPCClient(link)
116+
117+
const result = await client.planet.list(
118+
{ limit: 10 },
119+
{ context: { something: 'value' } }
120+
)
121+
```
122+
10123
:::info
11-
Interested in OpenAPILink support? Help us prioritize this feature by casting your vote on [Github](https://github.com/unnoq/orpc/issues/163)
124+
If a property in `ClientContext` is required, oRPC enforces its inclusion when calling procedures.
125+
:::
126+
127+
## SSE Like Behavior
128+
129+
Unlike traditional SSE, the [Event Iterator](/docs/event-iterator) does not automatically retry on error. To enable automatic retries, refer to the [Client Retry Plugin](/docs/plugins/client-retry).
130+
131+
## Event Iterator Keep Alive
132+
133+
:::warning
134+
These options for sending [Event Iterator](/docs/event-iterator) from **client to the server**, not from **the server to client** as used in [RPCHandler Event Iterator Keep Alive](/docs/rpc-handler#event-iterator-keep-alive) or [OpenAPIHandler Event Iterator Keep Alive](/docs/openapi/openapi-handler#event-iterator-keep-alive).
135+
136+
**In 99% of cases, you don't need to configure these options.**
12137
:::
138+
139+
To keep [Event Iterator](/docs/event-iterator) connections alive, `RPCLink` periodically sends a ping comment to the server. You can configure this behavior using the following options:
140+
141+
- `eventIteratorKeepAliveEnabled` (default: `true`) – Enables or disables pings.
142+
- `eventIteratorKeepAliveInterval` (default: `5000`) – Time between pings (in milliseconds).
143+
- `eventIteratorKeepAliveComment` (default: `''`) – Custom content for ping messages.
144+
145+
```ts
146+
const link = new OpenAPILink({
147+
eventIteratorKeepAliveEnabled: true,
148+
eventIteratorKeepAliveInterval: 5000, // 5 seconds
149+
eventIteratorKeepAliveComment: '',
150+
})
151+
```

apps/content/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"@orpc/arktype": "workspace:*",
1212
"@orpc/client": "workspace:*",
1313
"@orpc/contract": "workspace:*",
14+
"@orpc/openapi": "workspace:*",
15+
"@orpc/openapi-client": "workspace:*",
1416
"@orpc/react-query": "workspace:*",
1517
"@orpc/server": "workspace:*",
1618
"@orpc/valibot": "workspace:*",
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ClientContext, ClientLink, ClientOptions } from '../../types'
1+
import type { ClientContext } from '../../types'
22
import type { StandardRPCLinkOptions } from '../standard'
33
import type { LinkFetchClientOptions } from './link-fetch-client'
44
import { StandardLink, StandardRPCJsonSerializer, StandardRPCLinkCodec, StandardRPCSerializer } from '../standard'
@@ -7,19 +7,13 @@ import { LinkFetchClient } from './link-fetch-client'
77
export interface RPCLinkOptions<T extends ClientContext>
88
extends StandardRPCLinkOptions<T>, LinkFetchClientOptions<T> {}
99

10-
export class RPCLink<T extends ClientContext> implements ClientLink<T> {
11-
private readonly standardLink: StandardLink<T>
12-
10+
export class RPCLink<T extends ClientContext> extends StandardLink<T> {
1311
constructor(options: RPCLinkOptions<T>) {
1412
const jsonSerializer = new StandardRPCJsonSerializer(options)
1513
const serializer = new StandardRPCSerializer(jsonSerializer)
1614
const linkCodec = new StandardRPCLinkCodec(serializer, options)
1715
const linkClient = new LinkFetchClient(options)
1816

19-
this.standardLink = new StandardLink(linkCodec, linkClient, options)
20-
}
21-
22-
async call(path: readonly string[], input: unknown, options: ClientOptions<T>): Promise<unknown> {
23-
return this.standardLink.call(path, input, options)
17+
super(linkCodec, linkClient, options)
2418
}
2519
}

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

+14-36
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import * as StandardServer from '@orpc/standard-server'
12
import { ORPCError } from '../../error'
23
import { StandardRPCJsonSerializer } from './rpc-json-serializer'
34
import { StandardRPCLinkCodec } from './rpc-link-codec'
45
import { StandardRPCSerializer } from './rpc-serializer'
56

7+
const mergeStandardHeadersSpy = vi.spyOn(StandardServer, 'mergeStandardHeaders')
8+
69
beforeEach(() => {
710
vi.clearAllMocks()
811
})
@@ -81,45 +84,20 @@ describe('standardRPCLinkCodec', () => {
8184
expect(serializeSpy).toBeCalledWith(input)
8285
})
8386

84-
describe('last-event-id', async () => {
85-
it('should set', async () => {
86-
const codec = new StandardRPCLinkCodec(serializer, {
87-
url: 'http://localhost:3000',
88-
method,
89-
headers: () => ({ 'x-custom-header': 'custom-value' }),
90-
})
91-
92-
const request = await codec.encode(['test'], 'input', { context: {}, lastEventId: '1' })
93-
94-
expect(request.headers['x-custom-header']).toEqual('custom-value')
95-
expect(request.headers['last-event-id']).toEqual('1')
96-
})
97-
98-
it('should merged', async () => {
99-
const codec = new StandardRPCLinkCodec(serializer, {
100-
url: 'http://localhost:3000',
101-
method,
102-
headers: () => ({ 'x-custom-header': 'custom-value', 'last-event-id': '2' }),
103-
})
104-
105-
const request = await codec.encode(['test'], 'input', { context: {}, lastEventId: '1' })
106-
107-
expect(request.headers['x-custom-header']).toEqual('custom-value')
108-
expect(request.headers['last-event-id']).toEqual(['2', '1'])
87+
it('last-event-id', async () => {
88+
const codec = new StandardRPCLinkCodec(serializer, {
89+
url: 'http://localhost:3000',
90+
method,
91+
headers: () => ({ 'x-custom-header': 'custom-value' }),
10992
})
11093

111-
it('should merged 2', async () => {
112-
const codec = new StandardRPCLinkCodec(serializer, {
113-
url: 'http://localhost:3000',
114-
method,
115-
headers: () => ({ 'x-custom-header': 'custom-value', 'last-event-id': ['2', '3'] }),
116-
})
94+
const request = await codec.encode(['test'], 'input', { context: {}, lastEventId: '1' })
11795

118-
const request = await codec.encode(['test'], 'input', { context: {}, lastEventId: '1' })
96+
expect(request.headers['last-event-id']).toEqual('1')
11997

120-
expect(request.headers['x-custom-header']).toEqual('custom-value')
121-
expect(request.headers['last-event-id']).toEqual(['2', '3', '1'])
122-
})
98+
expect(mergeStandardHeadersSpy).toBeCalledWith({ 'x-custom-header': 'custom-value' }, { 'last-event-id': '1' })
99+
expect(mergeStandardHeadersSpy).toBeCalledTimes(1)
100+
expect(request.headers).toBe(mergeStandardHeadersSpy.mock.results[0]!.value)
123101
})
124102
})
125103

@@ -207,7 +185,7 @@ describe('standardRPCLinkCodec', () => {
207185
status: 403,
208186
headers: {},
209187
body: () => Promise.resolve(serialized),
210-
})).rejects.toThrow('Invalid RPC error response format.')
188+
})).rejects.toThrow('MALFORMED_ORPC_ERROR_RESPONSE')
211189

212190
expect(deserializeSpy).toBeCalledTimes(1)
213191
expect(deserializeSpy).toBeCalledWith(serialized)

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

+6-13
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { StandardHeaders, StandardLazyResponse, StandardRequest } from '@orpc/standard-server'
21
import type { ClientContext, ClientOptions, HTTPMethod } from '../../types'
32
import type { StandardRPCSerializer } from './rpc-serializer'
43
import type { StandardLinkCodec } from './types'
54
import { isAsyncIteratorObject, stringifyJSON, value, type Value } from '@orpc/shared'
5+
import { mergeStandardHeaders, type StandardHeaders, type StandardLazyResponse, type StandardRequest } from '@orpc/standard-server'
66
import { ORPCError } from '../../error'
77
import { toHttpPath } from './utils'
88

@@ -76,20 +76,12 @@ export class StandardRPCLinkCodec<T extends ClientContext> implements StandardLi
7676

7777
async encode(path: readonly string[], input: unknown, options: ClientOptions<T>): Promise<StandardRequest> {
7878
const expectedMethod = await value(this.expectedMethod, options, path, input)
79-
const headers = { ...await value(this.headers, options, path, input) }
79+
let headers = await value(this.headers, options, path, input)
8080
const baseUrl = await value(this.baseUrl, options, path, input)
8181
const url = new URL(`${baseUrl.toString().replace(/\/$/, '')}${toHttpPath(path)}`)
8282

8383
if (options.lastEventId !== undefined) {
84-
if (Array.isArray(headers['last-event-id'])) {
85-
headers['last-event-id'] = [...headers['last-event-id'], options.lastEventId]
86-
}
87-
else if (headers['last-event-id'] !== undefined) {
88-
headers['last-event-id'] = [headers['last-event-id'], options.lastEventId]
89-
}
90-
else {
91-
headers['last-event-id'] = options.lastEventId
92-
}
84+
headers = mergeStandardHeaders(headers, { 'last-event-id': options.lastEventId })
9385
}
9486

9587
const serialized = this.serializer.serialize(input)
@@ -155,8 +147,9 @@ export class StandardRPCLinkCodec<T extends ClientContext> implements StandardLi
155147
throw ORPCError.fromJSON(deserialized)
156148
}
157149

158-
throw new Error('Invalid RPC error response format.', {
159-
cause: deserialized,
150+
throw new ORPCError('MALFORMED_ORPC_ERROR_RESPONSE', {
151+
status: response.status,
152+
data: deserialized,
160153
})
161154
}
162155

packages/client/src/error.test.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ describe('oRPCError', () => {
4141
})
4242

4343
it('oRPCError throw when invalid status', () => {
44-
expect(() => new ORPCError('BAD_GATEWAY', { status: 100 })).toThrowError()
45-
expect(() => new ORPCError('BAD_GATEWAY', { status: -1 })).toThrowError()
44+
expect(() => new ORPCError('BAD_GATEWAY', { status: 200 })).toThrowError()
45+
expect(() => new ORPCError('BAD_GATEWAY', { status: 299 })).toThrowError()
46+
47+
expect(() => new ORPCError('BAD_GATEWAY', { status: 300 })).not.toThrowError()
48+
expect(() => new ORPCError('BAD_GATEWAY', { status: 199 })).not.toThrowError()
4649
})
4750

4851
it('toJSON', () => {

packages/client/src/error.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ export class ORPCError<TCode extends ORPCErrorCode, TData> extends Error {
104104
readonly data: TData
105105

106106
constructor(code: TCode, ...[options]: MaybeOptionalOptions<ORPCErrorOptions<TData>>) {
107-
if (options?.status && (options.status < 400 || options.status >= 600)) {
108-
throw new Error('[ORPCError] The error status code must be in the 400-599 range.')
107+
if (options?.status && (options.status >= 200 && options.status < 300)) {
108+
throw new Error('[ORPCError] The error status code must not be in the 200-299 range.')
109109
}
110110

111111
const message = fallbackORPCErrorMessage(code, options?.message)

packages/openapi-client/package.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@
2424
"types": "./dist/adapters/standard/index.d.mts",
2525
"import": "./dist/adapters/standard/index.mjs",
2626
"default": "./dist/adapters/standard/index.mjs"
27+
},
28+
"./fetch": {
29+
"types": "./dist/adapters/fetch/index.d.mts",
30+
"import": "./dist/adapters/fetch/index.mjs",
31+
"default": "./dist/adapters/fetch/index.mjs"
2732
}
2833
}
2934
},
3035
"exports": {
3136
".": "./src/index.ts",
32-
"./standard": "./src/adapters/standard/index.ts"
37+
"./standard": "./src/adapters/standard/index.ts",
38+
"./fetch": "./src/adapters/fetch/index.ts"
3339
},
3440
"files": [
3541
"dist"
@@ -41,7 +47,11 @@
4147
},
4248
"dependencies": {
4349
"@orpc/client": "workspace:*",
50+
"@orpc/contract": "workspace:*",
4451
"@orpc/shared": "workspace:*",
4552
"@orpc/standard-server": "workspace:*"
53+
},
54+
"devDependencies": {
55+
"@orpc/server": "workspace:*"
4656
}
4757
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './openapi-link'

0 commit comments

Comments
 (0)