Skip to content

Commit 3f73bd3

Browse files
authored
feat(server, openapi): add filter option to handlers (#770)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for filtering procedures in OpenAPI and RPC handlers, enabling selective inclusion of routes via custom filter functions. * Introduced a filter option in handler and generator configurations, replacing the deprecated exclude option for enhanced flexibility. * **Documentation** * Updated and expanded documentation to explain the new filtering procedures feature with detailed examples. * **Tests** * Added test cases confirming the filtering functionality works as intended in both OpenAPI and RPC matchers. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a4bc49e commit 3f73bd3

11 files changed

Lines changed: 147 additions & 20 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ export default async function fetch(request: Request) {
9090
}
9191
```
9292

93+
## Filtering Procedures
94+
95+
You can filter a procedure from matching by using the `filter` option:
96+
97+
```ts
98+
const handler = new OpenAPIHandler(router, {
99+
filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'),
100+
})
101+
```
102+
93103
## Event Iterator Keep Alive
94104

95105
To keep [Event Iterator](/docs/event-iterator) connections alive, `OpenAPIHandler` periodically sends a ping comment to the client. You can configure this behavior using the following options:

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,13 @@ const spec = await generator.generate(router, {
151151

152152
:::
153153

154-
## Excluding Procedures
154+
## Filtering Procedures
155155

156-
You can exclude a procedure from the OpenAPI specification using the `exclude` option:
156+
You can filter a procedure from the OpenAPI specification using the `filter` option:
157157

158158
```ts
159159
const spec = await generator.generate(router, {
160-
exclude: (procedure, path) => !!procedure['~orpc'].route.tags?.includes('admin'),
160+
filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'),
161161
})
162162
```
163163

apps/content/docs/rpc-handler.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ export default async function fetch(request: Request) {
7373
}
7474
```
7575

76+
## Filtering Procedures
77+
78+
You can filter a procedure from matching by using the `filter` option:
79+
80+
```ts
81+
const handler = new RPCHandler(router, {
82+
filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'),
83+
})
84+
```
85+
7686
## Event Iterator Keep Alive
7787

7888
To keep [Event Iterator](/docs/event-iterator) connections alive, `RPCHandler` periodically sends a ping comment to the client. You can configure this behavior using the following options:

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard'
22
import type { Context, Router } from '@orpc/server'
33
import type { StandardHandlerOptions } from '@orpc/server/standard'
4+
import type { StandardOpenAPIMatcherOptions } from './openapi-matcher'
45
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
56
import { StandardHandler } from '@orpc/server/standard'
67
import { StandardOpenAPICodec } from './openapi-codec'
78
import { StandardOpenAPIMatcher } from './openapi-matcher'
89

910
export interface StandardOpenAPIHandlerOptions<T extends Context>
10-
extends StandardHandlerOptions<T>, StandardOpenAPIJsonSerializerOptions, StandardBracketNotationSerializerOptions {}
11+
extends StandardHandlerOptions<T>, StandardOpenAPIJsonSerializerOptions,
12+
StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions {}
1113

1214
export class StandardOpenAPIHandler<T extends Context> extends StandardHandler<T> {
1315
constructor(router: Router<any, T>, options: NoInfer<StandardOpenAPIHandlerOptions<T>>) {
1416
const jsonSerializer = new StandardOpenAPIJsonSerializer(options)
1517
const bracketNotationSerializer = new StandardBracketNotationSerializer(options)
1618
const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer)
17-
const matcher = new StandardOpenAPIMatcher()
19+
const matcher = new StandardOpenAPIMatcher(options)
1820
const codec = new StandardOpenAPICodec(serializer)
1921

2022
super(router, matcher, codec, options)

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,26 @@ describe('standardOpenAPIMatcher', () => {
237237
params: undefined,
238238
})
239239
})
240+
241+
it('filter procedures', async () => {
242+
const rpcMatcher = new StandardOpenAPIMatcher({
243+
filter: (options) => {
244+
if (options.path.includes('ping')) {
245+
return false
246+
}
247+
248+
return true
249+
},
250+
})
251+
rpcMatcher.init(router)
252+
253+
expect(await rpcMatcher.match('POST', '/base')).toEqual(undefined)
254+
expect(await rpcMatcher.match('DELETE', '/ping/unnoq')).toEqual(undefined)
255+
256+
expect(await rpcMatcher.match('GET', '/pong/something')).toEqual({
257+
path: ['pong'],
258+
procedure: routedPong,
259+
params: { pong: 'something' },
260+
})
261+
})
240262
})

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
import type { HTTPPath } from '@orpc/client'
22
import type { AnyContractProcedure } from '@orpc/contract'
3-
import type { AnyProcedure, AnyRouter, LazyTraverseContractProceduresOptions } from '@orpc/server'
3+
import type { AnyProcedure, AnyRouter, ContractProcedureCallbackOptions, LazyTraverseContractProceduresOptions } from '@orpc/server'
44
import type { StandardMatcher, StandardMatchResult } from '@orpc/server/standard'
5+
import type { Value } from '@orpc/shared'
56
import { toHttpPath } from '@orpc/client/standard'
67
import { fallbackContractConfig } from '@orpc/contract'
78
import { createContractedProcedure, getLazyMeta, getRouter, isProcedure, traverseContractProcedures, unlazy } from '@orpc/server'
9+
import { value } from '@orpc/shared'
810
import { addRoute, createRouter, findRoute } from 'rou3'
911
import { decodeParams, toRou3Pattern } from './utils'
1012

13+
export interface StandardOpenAPIMatcherOptions {
14+
/**
15+
* Filter procedures. Return `false` to exclude a procedure from matching.
16+
*
17+
* @default true
18+
*/
19+
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>
20+
}
21+
1122
export class StandardOpenAPIMatcher implements StandardMatcher {
23+
private readonly filter: Exclude<StandardOpenAPIMatcherOptions['filter'], undefined>
24+
1225
private readonly tree = createRouter<{
1326
path: readonly string[]
1427
contract: AnyContractProcedure
@@ -18,8 +31,18 @@ export class StandardOpenAPIMatcher implements StandardMatcher {
1831

1932
private pendingRouters: (LazyTraverseContractProceduresOptions & { httpPathPrefix: HTTPPath, laziedPrefix: string | undefined }) [] = []
2033

34+
constructor(options: StandardOpenAPIMatcherOptions = {}) {
35+
this.filter = options.filter ?? true
36+
}
37+
2138
init(router: AnyRouter, path: readonly string[] = []): void {
22-
const laziedOptions = traverseContractProcedures({ router, path }, ({ path, contract }) => {
39+
const laziedOptions = traverseContractProcedures({ router, path }, (traverseOptions) => {
40+
if (!value(this.filter, traverseOptions)) {
41+
return
42+
}
43+
44+
const { path, contract } = traverseOptions
45+
2346
const method = fallbackContractConfig('defaultMethod', contract['~orpc'].route.method)
2447
const httpPath = toRou3Pattern(contract['~orpc'].route.path ?? toHttpPath(path))
2548

packages/openapi/src/openapi-generator.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { AnyContractProcedure, AnyContractRouter, AnySchema, ErrorMap, OpenAPI } from '@orpc/contract'
22
import type { StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard'
3-
import type { AnyProcedure, AnyRouter } from '@orpc/server'
3+
import type { AnyProcedure, AnyRouter, ContractProcedureCallbackOptions } from '@orpc/server'
4+
import type { Value } from '@orpc/shared'
45
import type { JSONSchema } from './schema'
56
import type { ConditionalSchemaConverter, SchemaConverter, SchemaConverterComponent, SchemaConvertOptions } from './schema-converter'
67
import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isORPCErrorStatus } from '@orpc/client'
78
import { toHttpPath } from '@orpc/client/standard'
89
import { fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract'
910
import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-client/standard'
1011
import { resolveContractProcedures } from '@orpc/server'
11-
import { clone, stringifyJSON, toArray } from '@orpc/shared'
12+
import { clone, stringifyJSON, toArray, value } from '@orpc/shared'
1213
import { applyCustomOpenAPIOperation } from './openapi-custom'
1314
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
1415
import { CompositeSchemaConverter } from './schema-converter'
@@ -24,10 +25,18 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial<Omit<OpenAPI.Do
2425
/**
2526
* Exclude procedures from the OpenAPI specification.
2627
*
28+
* @deprecated Use `filter` option instead.
2729
* @default () => false
2830
*/
2931
exclude?: (procedure: AnyProcedure | AnyContractProcedure, path: readonly string[]) => boolean
3032

33+
/**
34+
* Filter procedures. Return `false` to exclude a procedure from the OpenAPI specification.
35+
*
36+
* @default true
37+
*/
38+
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>
39+
3140
/**
3241
* Common schemas to be used for $ref resolution.
3342
*/
@@ -81,24 +90,30 @@ export class OpenAPIGenerator {
8190
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-specification OpenAPI Specification Docs}
8291
*/
8392
async generate(router: AnyContractRouter | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise<OpenAPI.Document> {
84-
const exclude = options.exclude ?? (() => false)
93+
const filter = options.filter
94+
?? (({ contract, path }: ContractProcedureCallbackOptions) => {
95+
return !(options.exclude?.(contract, path) ?? false)
96+
})
8597

8698
const doc: OpenAPI.Document = {
8799
...clone(options),
88100
info: options.info ?? { title: 'API Reference', version: '0.0.0' },
89101
openapi: '3.1.1',
90102
exclude: undefined,
103+
filter: undefined,
91104
commonSchemas: undefined,
92105
} as OpenAPI.Document
93106

94107
const { baseSchemaConvertOptions, undefinedErrorJsonSchema } = await this.#resolveCommonSchemas(doc, options.commonSchemas)
95108

96-
const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = []
109+
const contracts: ContractProcedureCallbackOptions[] = []
97110

98-
await resolveContractProcedures({ path: [], router }, ({ contract, path }) => {
99-
if (!exclude(contract, path)) {
100-
contracts.push({ contract, path })
111+
await resolveContractProcedures({ path: [], router }, (traverseOptions) => {
112+
if (!value(filter, traverseOptions)) {
113+
return
101114
}
115+
116+
contracts.push(traverseOptions)
102117
})
103118

104119
const errors: string[] = []

packages/server/src/adapters/standard/rpc-handler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@ import type { StandardRPCJsonSerializerOptions } from '@orpc/client/standard'
22
import type { Context } from '../../context'
33
import type { Router } from '../../router'
44
import type { StandardHandlerOptions } from './handler'
5+
import type { StandardRPCMatcherOptions } from './rpc-matcher'
56
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
67
import { StandardHandler } from './handler'
78
import { StandardRPCCodec } from './rpc-codec'
89
import { StandardRPCMatcher } from './rpc-matcher'
910

10-
export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {
11+
export interface StandardRPCHandlerOptions<T extends Context>
12+
extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions, StandardRPCMatcherOptions {
1113
}
1214

1315
export class StandardRPCHandler<T extends Context> extends StandardHandler<T> {
1416
constructor(router: Router<any, T>, options: StandardRPCHandlerOptions<T> = {}) {
1517
const jsonSerializer = new StandardRPCJsonSerializer(options)
1618
const serializer = new StandardRPCSerializer(jsonSerializer)
17-
const matcher = new StandardRPCMatcher()
19+
const matcher = new StandardRPCMatcher(options)
1820
const codec = new StandardRPCCodec(serializer)
1921

2022
super(router, matcher, codec, options)

packages/server/src/adapters/standard/rpc-matcher.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,25 @@ describe('standardRPCMatcher', () => {
168168
expect(pingLoader).toHaveBeenCalledTimes(4)
169169
expect(pongLoader).toHaveBeenCalledTimes(4)
170170
})
171+
172+
it('filter procedures', async () => {
173+
const rpcMatcher = new StandardRPCMatcher({
174+
filter: (options) => {
175+
if (options.path.includes('ping')) {
176+
return false
177+
}
178+
179+
return true
180+
},
181+
})
182+
rpcMatcher.init(router)
183+
184+
expect(await rpcMatcher.match('ANYTHING', '/ping')).toEqual(undefined)
185+
expect(await rpcMatcher.match('ANYTHING', '/nested/ping')).toEqual(undefined)
186+
187+
expect(await rpcMatcher.match('ANYTHING', '/pong')).toEqual({
188+
path: ['pong'],
189+
procedure: pong,
190+
})
191+
})
171192
})

packages/server/src/adapters/standard/rpc-matcher.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import type { HTTPPath } from '@orpc/client'
22
import type { AnyContractProcedure } from '@orpc/contract'
3+
import type { Value } from '@orpc/shared'
34
import type { AnyProcedure } from '../../procedure'
45
import type { AnyRouter } from '../../router'
5-
import type { LazyTraverseContractProceduresOptions } from '../../router-utils'
6+
import type { ContractProcedureCallbackOptions, LazyTraverseContractProceduresOptions } from '../../router-utils'
67
import type { StandardMatcher, StandardMatchResult } from './types'
78
import { toHttpPath } from '@orpc/client/standard'
8-
import { NullProtoObj } from '@orpc/shared'
9+
import { NullProtoObj, value } from '@orpc/shared'
910
import { unlazy } from '../../lazy'
1011
import { isProcedure } from '../../procedure'
1112
import { createContractedProcedure } from '../../procedure-utils'
1213
import { getRouter, traverseContractProcedures } from '../../router-utils'
1314

15+
export interface StandardRPCMatcherOptions {
16+
/**
17+
* Filter procedures. Return `false` to exclude a procedure from matching.
18+
*
19+
* @default true
20+
*/
21+
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>
22+
}
23+
1424
export class StandardRPCMatcher implements StandardMatcher {
25+
private readonly filter: Exclude<StandardRPCMatcherOptions['filter'], undefined>
26+
1527
private readonly tree: Record<
1628
HTTPPath,
1729
{
@@ -24,8 +36,18 @@ export class StandardRPCMatcher implements StandardMatcher {
2436

2537
private pendingRouters: (LazyTraverseContractProceduresOptions & { httpPathPrefix: HTTPPath }) [] = []
2638

39+
constructor(options: StandardRPCMatcherOptions = {}) {
40+
this.filter = options.filter ?? true
41+
}
42+
2743
init(router: AnyRouter, path: readonly string[] = []): void {
28-
const laziedOptions = traverseContractProcedures({ router, path }, ({ path, contract }) => {
44+
const laziedOptions = traverseContractProcedures({ router, path }, (traverseOptions) => {
45+
if (!value(this.filter, traverseOptions)) {
46+
return
47+
}
48+
49+
const { path, contract } = traverseOptions
50+
2951
const httpPath = toHttpPath(path)
3052

3153
if (isProcedure(contract)) {

0 commit comments

Comments
 (0)