Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/content/docs/openapi/openapi-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export default async function fetch(request: Request) {
}
```

## Filtering Procedures

You can filter a procedure from matching by using the `filter` option:

```ts
const handler = new OpenAPIHandler(router, {
filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'),
})
```

## Event Iterator Keep Alive

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:
Expand Down
6 changes: 3 additions & 3 deletions apps/content/docs/openapi/openapi-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,13 @@ const spec = await generator.generate(router, {

:::

## Excluding Procedures
## Filtering Procedures

You can exclude a procedure from the OpenAPI specification using the `exclude` option:
You can filter a procedure from the OpenAPI specification using the `filter` option:

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

Expand Down
10 changes: 10 additions & 0 deletions apps/content/docs/rpc-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export default async function fetch(request: Request) {
}
```

## Filtering Procedures

You can filter a procedure from matching by using the `filter` option:

```ts
const handler = new RPCHandler(router, {
filter: ({ contract, path }) => !contract['~orpc'].route.tags?.includes('internal'),
})
```

## Event Iterator Keep Alive

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:
Expand Down
6 changes: 4 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-handler.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard'
import type { Context, Router } from '@orpc/server'
import type { StandardHandlerOptions } from '@orpc/server/standard'
import type { StandardOpenAPIMatcherOptions } from './openapi-matcher'
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
import { StandardHandler } from '@orpc/server/standard'
import { StandardOpenAPICodec } from './openapi-codec'
import { StandardOpenAPIMatcher } from './openapi-matcher'

export interface StandardOpenAPIHandlerOptions<T extends Context>
extends StandardHandlerOptions<T>, StandardOpenAPIJsonSerializerOptions, StandardBracketNotationSerializerOptions {}
extends StandardHandlerOptions<T>, StandardOpenAPIJsonSerializerOptions,
StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions {}

export class StandardOpenAPIHandler<T extends Context> extends StandardHandler<T> {
constructor(router: Router<any, T>, options: NoInfer<StandardOpenAPIHandlerOptions<T>>) {
const jsonSerializer = new StandardOpenAPIJsonSerializer(options)
const bracketNotationSerializer = new StandardBracketNotationSerializer(options)
const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer)
const matcher = new StandardOpenAPIMatcher()
const matcher = new StandardOpenAPIMatcher(options)
const codec = new StandardOpenAPICodec(serializer)

super(router, matcher, codec, options)
Expand Down
22 changes: 22 additions & 0 deletions packages/openapi/src/adapters/standard/openapi-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,26 @@ describe('standardOpenAPIMatcher', () => {
params: undefined,
})
})

it('filter procedures', async () => {
const rpcMatcher = new StandardOpenAPIMatcher({
filter: (options) => {
if (options.path.includes('ping')) {
return false
}

return true
Comment thread
dinwwwh marked this conversation as resolved.
},
})
rpcMatcher.init(router)

expect(await rpcMatcher.match('POST', '/base')).toEqual(undefined)
expect(await rpcMatcher.match('DELETE', '/ping/unnoq')).toEqual(undefined)

expect(await rpcMatcher.match('GET', '/pong/something')).toEqual({
path: ['pong'],
procedure: routedPong,
params: { pong: 'something' },
})
})
})
27 changes: 25 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-matcher.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import type { HTTPPath } from '@orpc/client'
import type { AnyContractProcedure } from '@orpc/contract'
import type { AnyProcedure, AnyRouter, LazyTraverseContractProceduresOptions } from '@orpc/server'
import type { AnyProcedure, AnyRouter, ContractProcedureCallbackOptions, LazyTraverseContractProceduresOptions } from '@orpc/server'
import type { StandardMatcher, StandardMatchResult } from '@orpc/server/standard'
import type { Value } from '@orpc/shared'
import { toHttpPath } from '@orpc/client/standard'
import { fallbackContractConfig } from '@orpc/contract'
import { createContractedProcedure, getLazyMeta, getRouter, isProcedure, traverseContractProcedures, unlazy } from '@orpc/server'
import { value } from '@orpc/shared'
import { addRoute, createRouter, findRoute } from 'rou3'
import { decodeParams, toRou3Pattern } from './utils'

export interface StandardOpenAPIMatcherOptions {
/**
* Filter procedures. Return `false` to exclude a procedure from matching.
*
* @default true
*/
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>
}

export class StandardOpenAPIMatcher implements StandardMatcher {
private readonly filter: Exclude<StandardOpenAPIMatcherOptions['filter'], undefined>

private readonly tree = createRouter<{
path: readonly string[]
contract: AnyContractProcedure
Expand All @@ -18,8 +31,18 @@ export class StandardOpenAPIMatcher implements StandardMatcher {

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

constructor(options: StandardOpenAPIMatcherOptions = {}) {
this.filter = options.filter ?? true
}

init(router: AnyRouter, path: readonly string[] = []): void {
const laziedOptions = traverseContractProcedures({ router, path }, ({ path, contract }) => {
const laziedOptions = traverseContractProcedures({ router, path }, (traverseOptions) => {
if (!value(this.filter, traverseOptions)) {
return
}

const { path, contract } = traverseOptions

const method = fallbackContractConfig('defaultMethod', contract['~orpc'].route.method)
const httpPath = toRou3Pattern(contract['~orpc'].route.path ?? toHttpPath(path))

Expand Down
29 changes: 22 additions & 7 deletions packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { AnyContractProcedure, AnyContractRouter, AnySchema, ErrorMap, OpenAPI } from '@orpc/contract'
import type { StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard'
import type { AnyProcedure, AnyRouter } from '@orpc/server'
import type { AnyProcedure, AnyRouter, ContractProcedureCallbackOptions } from '@orpc/server'
import type { Value } from '@orpc/shared'
import type { JSONSchema } from './schema'
import type { ConditionalSchemaConverter, SchemaConverter, SchemaConverterComponent, SchemaConvertOptions } from './schema-converter'
import { fallbackORPCErrorMessage, fallbackORPCErrorStatus, isORPCErrorStatus } from '@orpc/client'
import { toHttpPath } from '@orpc/client/standard'
import { fallbackContractConfig, getEventIteratorSchemaDetails } from '@orpc/contract'
import { getDynamicParams, StandardOpenAPIJsonSerializer } from '@orpc/openapi-client/standard'
import { resolveContractProcedures } from '@orpc/server'
import { clone, stringifyJSON, toArray } from '@orpc/shared'
import { clone, stringifyJSON, toArray, value } from '@orpc/shared'
import { applyCustomOpenAPIOperation } from './openapi-custom'
import { checkParamsSchema, resolveOpenAPIJsonSchemaRef, toOpenAPIContent, toOpenAPIEventIteratorContent, toOpenAPIMethod, toOpenAPIParameters, toOpenAPIPath, toOpenAPISchema } from './openapi-utils'
import { CompositeSchemaConverter } from './schema-converter'
Expand All @@ -24,10 +25,18 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial<Omit<OpenAPI.Do
/**
* Exclude procedures from the OpenAPI specification.
*
* @deprecated Use `filter` option instead.
* @default () => false
*/
exclude?: (procedure: AnyProcedure | AnyContractProcedure, path: readonly string[]) => boolean

/**
* Filter procedures. Return `false` to exclude a procedure from the OpenAPI specification.
*
* @default true
*/
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>

/**
* Common schemas to be used for $ref resolution.
*/
Expand Down Expand Up @@ -81,24 +90,30 @@ export class OpenAPIGenerator {
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-specification OpenAPI Specification Docs}
*/
async generate(router: AnyContractRouter | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise<OpenAPI.Document> {
const exclude = options.exclude ?? (() => false)
const filter = options.filter
?? (({ contract, path }: ContractProcedureCallbackOptions) => {
return !(options.exclude?.(contract, path) ?? false)
})

const doc: OpenAPI.Document = {
...clone(options),
info: options.info ?? { title: 'API Reference', version: '0.0.0' },
openapi: '3.1.1',
exclude: undefined,
filter: undefined,
commonSchemas: undefined,
} as OpenAPI.Document

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

const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = []
const contracts: ContractProcedureCallbackOptions[] = []

await resolveContractProcedures({ path: [], router }, ({ contract, path }) => {
if (!exclude(contract, path)) {
contracts.push({ contract, path })
await resolveContractProcedures({ path: [], router }, (traverseOptions) => {
if (!value(filter, traverseOptions)) {
return
}

contracts.push(traverseOptions)
})

const errors: string[] = []
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/adapters/standard/rpc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import type { StandardRPCJsonSerializerOptions } from '@orpc/client/standard'
import type { Context } from '../../context'
import type { Router } from '../../router'
import type { StandardHandlerOptions } from './handler'
import type { StandardRPCMatcherOptions } from './rpc-matcher'
import { StandardRPCJsonSerializer, StandardRPCSerializer } from '@orpc/client/standard'
import { StandardHandler } from './handler'
import { StandardRPCCodec } from './rpc-codec'
import { StandardRPCMatcher } from './rpc-matcher'

export interface StandardRPCHandlerOptions<T extends Context> extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions {
export interface StandardRPCHandlerOptions<T extends Context>
extends StandardHandlerOptions<T>, StandardRPCJsonSerializerOptions, StandardRPCMatcherOptions {
}

export class StandardRPCHandler<T extends Context> extends StandardHandler<T> {
constructor(router: Router<any, T>, options: StandardRPCHandlerOptions<T> = {}) {
const jsonSerializer = new StandardRPCJsonSerializer(options)
const serializer = new StandardRPCSerializer(jsonSerializer)
const matcher = new StandardRPCMatcher()
const matcher = new StandardRPCMatcher(options)
const codec = new StandardRPCCodec(serializer)

super(router, matcher, codec, options)
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/adapters/standard/rpc-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,25 @@ describe('standardRPCMatcher', () => {
expect(pingLoader).toHaveBeenCalledTimes(4)
expect(pongLoader).toHaveBeenCalledTimes(4)
})

it('filter procedures', async () => {
const rpcMatcher = new StandardRPCMatcher({
filter: (options) => {
if (options.path.includes('ping')) {
return false
}

return true
Comment thread
dinwwwh marked this conversation as resolved.
},
})
rpcMatcher.init(router)

expect(await rpcMatcher.match('ANYTHING', '/ping')).toEqual(undefined)
expect(await rpcMatcher.match('ANYTHING', '/nested/ping')).toEqual(undefined)

expect(await rpcMatcher.match('ANYTHING', '/pong')).toEqual({
path: ['pong'],
procedure: pong,
})
})
})
28 changes: 25 additions & 3 deletions packages/server/src/adapters/standard/rpc-matcher.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import type { HTTPPath } from '@orpc/client'
import type { AnyContractProcedure } from '@orpc/contract'
import type { Value } from '@orpc/shared'
import type { AnyProcedure } from '../../procedure'
import type { AnyRouter } from '../../router'
import type { LazyTraverseContractProceduresOptions } from '../../router-utils'
import type { ContractProcedureCallbackOptions, LazyTraverseContractProceduresOptions } from '../../router-utils'
import type { StandardMatcher, StandardMatchResult } from './types'
import { toHttpPath } from '@orpc/client/standard'
import { NullProtoObj } from '@orpc/shared'
import { NullProtoObj, value } from '@orpc/shared'
import { unlazy } from '../../lazy'
import { isProcedure } from '../../procedure'
import { createContractedProcedure } from '../../procedure-utils'
import { getRouter, traverseContractProcedures } from '../../router-utils'

export interface StandardRPCMatcherOptions {
/**
* Filter procedures. Return `false` to exclude a procedure from matching.
*
* @default true
*/
filter?: Value<boolean, [options: ContractProcedureCallbackOptions]>
}

export class StandardRPCMatcher implements StandardMatcher {
private readonly filter: Exclude<StandardRPCMatcherOptions['filter'], undefined>

private readonly tree: Record<
HTTPPath,
{
Expand All @@ -24,8 +36,18 @@ export class StandardRPCMatcher implements StandardMatcher {

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

constructor(options: StandardRPCMatcherOptions = {}) {
this.filter = options.filter ?? true
}

init(router: AnyRouter, path: readonly string[] = []): void {
const laziedOptions = traverseContractProcedures({ router, path }, ({ path, contract }) => {
const laziedOptions = traverseContractProcedures({ router, path }, (traverseOptions) => {
if (!value(this.filter, traverseOptions)) {
return
}

const { path, contract } = traverseOptions

const httpPath = toHttpPath(path)

if (isProcedure(contract)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/router-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export interface TraverseContractProceduresOptions {
}

export interface ContractProcedureCallbackOptions {
contract: AnyContractProcedure
contract: AnyContractProcedure | AnyProcedure
path: readonly string[]
}

Expand Down