Skip to content

Commit b5e327f

Browse files
authored
feat(client): adapter interceptor (#899)
LinkFetchClient, RPCLink, OpenAPILink, ... now support `adapterInterceptors` option for intercept fetch request/response <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Plugin support for the fetch client with ordered runtime initialization. - Adapter interceptors to customize request/response handling. - Client options expanded to accept plugins and interceptors. - Refactor - Client constructor now accepts a single consolidated options object. - Handler option shapes adjusted to reorganize and restrict where plugins are provided. - Tests - Added unit and type tests for plugin composition and interceptor execution. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 39c8cfb commit b5e327f

File tree

12 files changed

+155
-18
lines changed

12 files changed

+155
-18
lines changed

apps/content/docs/advanced/superjson.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ import { StrictGetMethodPlugin } from '@orpc/server/plugins'
8383
import type { StandardHandlerOptions } from '@orpc/server/standard'
8484
import { StandardHandler, StandardRPCCodec, StandardRPCMatcher } from '@orpc/server/standard'
8585

86-
export interface SuperJSONHandlerOptions<T extends Context> extends StandardHandlerOptions<T> {
86+
export interface SuperJSONHandlerOptions<T extends Context>
87+
extends FetchHandlerOptions<T>, Omit<StandardHandlerOptions<T>, 'plugins'> {
8788
/**
8889
* Enable or disable the StrictGetMethodPlugin.
8990
*
@@ -93,7 +94,7 @@ export interface SuperJSONHandlerOptions<T extends Context> extends StandardHand
9394
}
9495

9596
export class SuperJSONHandler<T extends Context> extends FetchHandler<T> {
96-
constructor(router: Router<any, T>, options: NoInfer<FetchHandlerOptions<T> & SuperJSONHandlerOptions<T>> = {}) {
97+
constructor(router: Router<any, T>, options: NoInfer<SuperJSONHandlerOptions<T>> = {}) {
9798
options.plugins ??= []
9899

99100
const strictGetMethodPluginEnabled = options.strictGetMethodPluginEnabled ?? true
@@ -126,7 +127,9 @@ import type { LinkFetchClientOptions } from '@orpc/client/fetch'
126127
import { LinkFetchClient } from '@orpc/client/fetch'
127128

128129
export interface SuperJSONLinkOptions<T extends ClientContext>
129-
extends StandardLinkOptions<T>, StandardRPCLinkCodecOptions<T>, LinkFetchClientOptions<T> { }
130+
extends LinkFetchClientOptions<T>,
131+
Omit<StandardLinkOptions<T>, 'plugins'>,
132+
StandardRPCLinkCodecOptions<T> { }
130133

131134
export class SuperJSONLink<T extends ClientContext> extends StandardLink<T> {
132135
constructor(options: SuperJSONLinkOptions<T>) {

packages/client/src/adapters/fetch/link-fetch-client.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ const toStandardLazyResponseSpy = vi.spyOn(StandardServerFetch, 'toStandardLazyR
88
describe('linkFetchClient', () => {
99
it('call', async () => {
1010
const fetch = vi.fn().mockResolvedValueOnce(new Response('body'))
11-
const client = new LinkFetchClient({
11+
const interceptor1 = vi.fn(({ next }) => next())
12+
const interceptor2 = vi.fn(({ next }) => next())
13+
14+
const linkOptions = {
1215
fetch,
13-
})
16+
adapterInterceptors: [interceptor1, interceptor2],
17+
}
18+
19+
const client = new LinkFetchClient(linkOptions)
1420

1521
const standardRequest: StandardRequest = {
1622
url: new URL('http://localhost:300/example'),
@@ -31,7 +37,7 @@ describe('linkFetchClient', () => {
3137
const response = await client.call(standardRequest, options, ['example'], { body: true })
3238

3339
expect(toFetchRequestSpy).toBeCalledTimes(1)
34-
expect(toFetchRequestSpy).toBeCalledWith(standardRequest, { fetch })
40+
expect(toFetchRequestSpy).toBeCalledWith(standardRequest, linkOptions)
3541

3642
expect(response).toBe(toStandardLazyResponseSpy.mock.results[0]!.value)
3743
expect(toStandardLazyResponseSpy).toBeCalledTimes(1)
@@ -48,5 +54,39 @@ describe('linkFetchClient', () => {
4854
['example'],
4955
{ body: true },
5056
)
57+
58+
expect(interceptor1).toBeCalledTimes(1)
59+
expect(interceptor2).toBeCalledTimes(1)
60+
expect(interceptor1).toBeCalledWith(expect.objectContaining({
61+
request: toFetchRequestSpy.mock.results[0]!.value,
62+
...options,
63+
init: { redirect: 'manual' },
64+
input: { body: true },
65+
path: ['example'],
66+
}))
67+
expect(interceptor2).toBeCalledWith(expect.objectContaining({
68+
request: toFetchRequestSpy.mock.results[0]!.value,
69+
...options,
70+
init: { redirect: 'manual' },
71+
input: { body: true },
72+
path: ['example'],
73+
}))
74+
})
75+
76+
it('plugins', () => {
77+
const initRuntimeAdapter = vi.fn()
78+
const interceptor = vi.fn()
79+
80+
const linkOptions = {
81+
plugins: [
82+
{ initRuntimeAdapter },
83+
],
84+
adapterInterceptors: [interceptor],
85+
}
86+
87+
const link = new LinkFetchClient(linkOptions)
88+
89+
expect(initRuntimeAdapter).toHaveBeenCalledOnce()
90+
expect(initRuntimeAdapter).toHaveBeenCalledWith(linkOptions)
5191
})
5292
})

packages/client/src/adapters/fetch/link-fetch-client.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,59 @@
1+
import type { Interceptor } from '@orpc/shared'
12
import type { StandardLazyResponse, StandardRequest } from '@orpc/standard-server'
23
import type { ToFetchRequestOptions } from '@orpc/standard-server-fetch'
34
import type { ClientContext, ClientOptions } from '../../types'
45
import type { StandardLinkClient } from '../standard'
6+
import type { LinkFetchPlugin } from './plugin'
7+
import { intercept, toArray } from '@orpc/shared'
58
import { toFetchRequest, toStandardLazyResponse } from '@orpc/standard-server-fetch'
9+
import { CompositeLinkFetchPlugin } from './plugin'
10+
11+
export interface LinkFetchInterceptorOptions<T extends ClientContext> extends ClientOptions<T> {
12+
request: Request
13+
init: { redirect?: Request['redirect'] }
14+
path: readonly string[]
15+
input: unknown
16+
}
617

718
export interface LinkFetchClientOptions<T extends ClientContext> extends ToFetchRequestOptions {
819
fetch?: (
920
request: Request,
10-
init: { redirect?: Request['redirect'] },
21+
init: LinkFetchInterceptorOptions<T>['init'],
1122
options: ClientOptions<T>,
1223
path: readonly string[],
1324
input: unknown,
1425
) => Promise<Response>
26+
27+
adapterInterceptors?: Interceptor<LinkFetchInterceptorOptions<T>, Promise<Response>>[]
28+
29+
plugins?: LinkFetchPlugin<T>[]
1530
}
1631

1732
export class LinkFetchClient<T extends ClientContext> implements StandardLinkClient<T> {
1833
private readonly fetch: Exclude<LinkFetchClientOptions<T>['fetch'], undefined>
1934
private readonly toFetchRequestOptions: ToFetchRequestOptions
35+
private readonly adapterInterceptors: Exclude<LinkFetchClientOptions<T>['adapterInterceptors'], undefined>
2036

2137
constructor(options: LinkFetchClientOptions<T>) {
22-
this.fetch = options?.fetch ?? globalThis.fetch.bind(globalThis)
38+
const plugin = new CompositeLinkFetchPlugin(options.plugins)
39+
40+
plugin.initRuntimeAdapter(options)
41+
42+
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis)
2343
this.toFetchRequestOptions = options
44+
this.adapterInterceptors = toArray(options.adapterInterceptors)
2445
}
2546

26-
async call(request: StandardRequest, options: ClientOptions<T>, path: readonly string[], input: unknown): Promise<StandardLazyResponse> {
27-
const fetchRequest = toFetchRequest(request, this.toFetchRequestOptions)
47+
async call(standardRequest: StandardRequest, options: ClientOptions<T>, path: readonly string[], input: unknown): Promise<StandardLazyResponse> {
48+
const request = toFetchRequest(standardRequest, this.toFetchRequestOptions)
2849

29-
const fetchResponse = await this.fetch(fetchRequest, { redirect: 'manual' }, options, path, input)
50+
const fetchResponse = await intercept(
51+
this.adapterInterceptors,
52+
{ ...options, request, path, input, init: { redirect: 'manual' } },
53+
({ request, path, input, init, ...options }) => this.fetch(request, init, options, path, input),
54+
)
3055

31-
const lazyResponse = toStandardLazyResponse(fetchResponse, { signal: fetchRequest.signal })
56+
const lazyResponse = toStandardLazyResponse(fetchResponse, { signal: request.signal })
3257

3358
return lazyResponse
3459
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { StandardLinkPlugin } from '../standard'
2+
import type { LinkFetchPlugin } from './plugin'
3+
4+
describe('LinkFetchPlugin', () => {
5+
it('backward compatibility', () => {
6+
expectTypeOf<LinkFetchPlugin<{ a: string }>>().toExtend<StandardLinkPlugin<{ a: string }>>()
7+
expectTypeOf<StandardLinkPlugin<{ a: string }>>().toExtend<LinkFetchPlugin<{ a: string }>>()
8+
})
9+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { LinkFetchPlugin } from './plugin'
2+
import { CompositeLinkFetchPlugin } from './plugin'
3+
4+
describe('compositeLinkFetchPlugin', () => {
5+
it('forward initRuntimeAdapter and sort plugins', () => {
6+
const plugin1 = {
7+
initRuntimeAdapter: vi.fn(),
8+
order: 1,
9+
} satisfies LinkFetchPlugin<any>
10+
const plugin2 = {
11+
initRuntimeAdapter: vi.fn(),
12+
} satisfies LinkFetchPlugin<any>
13+
const plugin3 = {
14+
initRuntimeAdapter: vi.fn(),
15+
order: -1,
16+
} satisfies LinkFetchPlugin<any>
17+
18+
const compositePlugin = new CompositeLinkFetchPlugin([plugin1, plugin2, plugin3])
19+
20+
const interceptor = vi.fn()
21+
22+
const options = { adapterInterceptors: [interceptor] }
23+
24+
compositePlugin.initRuntimeAdapter(options)
25+
26+
expect(plugin1.initRuntimeAdapter).toHaveBeenCalledOnce()
27+
expect(plugin2.initRuntimeAdapter).toHaveBeenCalledOnce()
28+
expect(plugin3.initRuntimeAdapter).toHaveBeenCalledOnce()
29+
30+
expect(plugin1.initRuntimeAdapter.mock.calls[0]![0]).toBe(options)
31+
expect(plugin2.initRuntimeAdapter.mock.calls[0]![0]).toBe(options)
32+
expect(plugin3.initRuntimeAdapter.mock.calls[0]![0]).toBe(options)
33+
34+
expect(plugin3.initRuntimeAdapter).toHaveBeenCalledBefore(plugin2.initRuntimeAdapter)
35+
expect(plugin2.initRuntimeAdapter).toHaveBeenCalledBefore(plugin1.initRuntimeAdapter)
36+
})
37+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { ClientContext } from '../../types'
2+
import type { StandardLinkPlugin } from '../standard'
3+
import type { LinkFetchClientOptions } from './link-fetch-client'
4+
import { CompositeStandardLinkPlugin } from '../standard'
5+
6+
export interface LinkFetchPlugin<T extends ClientContext> extends StandardLinkPlugin<T> {
7+
initRuntimeAdapter?(options: LinkFetchClientOptions<T>): void
8+
}
9+
10+
export class CompositeLinkFetchPlugin<T extends ClientContext, TPlugin extends LinkFetchPlugin<T>>
11+
extends CompositeStandardLinkPlugin<T, TPlugin> implements LinkFetchPlugin<T> {
12+
initRuntimeAdapter(options: LinkFetchClientOptions<T>): void {
13+
for (const plugin of this.plugins) {
14+
plugin.initRuntimeAdapter?.(options)
15+
}
16+
}
17+
}

packages/client/src/adapters/fetch/rpc-link.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { StandardRPCLink } from '../standard'
55
import { LinkFetchClient } from './link-fetch-client'
66

77
export interface RPCLinkOptions<T extends ClientContext>
8-
extends StandardRPCLinkOptions<T>, LinkFetchClientOptions<T> {}
8+
extends LinkFetchClientOptions<T>, Omit<StandardRPCLinkOptions<T>, 'plugins'> {}
99

1010
/**
1111
* The RPC Link communicates with the server using the RPC protocol.

packages/openapi-client/src/adapters/fetch/openapi-link.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { LinkFetchClient } from '@orpc/client/fetch'
66
import { StandardOpenAPILink } from '../standard'
77

88
export interface OpenAPILinkOptions<T extends ClientContext>
9-
extends StandardOpenAPILinkOptions<T>, LinkFetchClientOptions<T> { }
9+
extends LinkFetchClientOptions<T>, Omit<StandardOpenAPILinkOptions<T>, 'plugins'> { }
1010

1111
/**
1212
* The OpenAPI Link for fetch runtime communicates with the server that follow the OpenAPI specification.

packages/openapi/src/adapters/fetch/openapi-handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import type { StandardOpenAPIHandlerOptions } from '../standard'
44
import { FetchHandler } from '@orpc/server/fetch'
55
import { StandardOpenAPIHandler } from '../standard'
66

7+
export interface OpenAPIHandlerOptions<T extends Context> extends FetchHandlerOptions<T>, Omit<StandardOpenAPIHandlerOptions<T>, 'plugins'> {
8+
}
9+
710
/**
811
* OpenAPI Handler for Fetch Server
912
*
1013
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-handler OpenAPI Handler Docs}
1114
* @see {@link https://orpc.unnoq.com/docs/adapters/http HTTP Adapter Docs}
1215
*/
1316
export class OpenAPIHandler<T extends Context> extends FetchHandler<T> {
14-
constructor(router: Router<any, T>, options: NoInfer<StandardOpenAPIHandlerOptions<T> & FetchHandlerOptions<T>> = {}) {
17+
constructor(router: Router<any, T>, options: NoInfer<OpenAPIHandlerOptions<T>> = {}) {
1518
super(new StandardOpenAPIHandler(router, options), options)
1619
}
1720
}

packages/openapi/src/adapters/node/openapi-handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import type { StandardOpenAPIHandlerOptions } from '../standard'
44
import { NodeHttpHandler } from '@orpc/server/node'
55
import { StandardOpenAPIHandler } from '../standard'
66

7+
export interface OpenAPIHandlerOptions<T extends Context> extends NodeHttpHandlerOptions<T>, Omit<StandardOpenAPIHandlerOptions<T>, 'plugins'> {
8+
}
9+
710
/**
811
* OpenAPI Handler for Node Server
912
*
1013
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-handler OpenAPI Handler Docs}
1114
* @see {@link https://orpc.unnoq.com/docs/adapters/http HTTP Adapter Docs}
1215
*/
1316
export class OpenAPIHandler<T extends Context> extends NodeHttpHandler<T> {
14-
constructor(router: Router<any, T>, options: NoInfer<StandardOpenAPIHandlerOptions<T> & NodeHttpHandlerOptions<T>> = {}) {
17+
constructor(router: Router<any, T>, options: NoInfer<OpenAPIHandlerOptions<T>> = {}) {
1518
super(new StandardOpenAPIHandler(router, options), options)
1619
}
1720
}

0 commit comments

Comments
 (0)