diff --git a/apps/content/docs/openapi/openapi-handler.md b/apps/content/docs/openapi/openapi-handler.md index 25e5b11c4..745d57162 100644 --- a/apps/content/docs/openapi/openapi-handler.md +++ b/apps/content/docs/openapi/openapi-handler.md @@ -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: diff --git a/apps/content/docs/openapi/openapi-specification.md b/apps/content/docs/openapi/openapi-specification.md index 76eee7768..b689a389c 100644 --- a/apps/content/docs/openapi/openapi-specification.md +++ b/apps/content/docs/openapi/openapi-specification.md @@ -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'), }) ``` diff --git a/apps/content/docs/rpc-handler.md b/apps/content/docs/rpc-handler.md index b48084958..c4814780c 100644 --- a/apps/content/docs/rpc-handler.md +++ b/apps/content/docs/rpc-handler.md @@ -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: diff --git a/packages/openapi/src/adapters/standard/openapi-handler.ts b/packages/openapi/src/adapters/standard/openapi-handler.ts index e437914e1..db4e88766 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler.ts @@ -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 - extends StandardHandlerOptions, StandardOpenAPIJsonSerializerOptions, StandardBracketNotationSerializerOptions {} + extends StandardHandlerOptions, StandardOpenAPIJsonSerializerOptions, + StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions {} export class StandardOpenAPIHandler extends StandardHandler { constructor(router: Router, options: NoInfer>) { 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) diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts index c89ed8ddb..4569b00ef 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.test.ts @@ -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 + }, + }) + 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' }, + }) + }) }) diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 6de18903a..e6a3f6c45 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -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 +} + export class StandardOpenAPIMatcher implements StandardMatcher { + private readonly filter: Exclude + private readonly tree = createRouter<{ path: readonly string[] contract: AnyContractProcedure @@ -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)) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 23c402dd0..e524683cc 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -1,6 +1,7 @@ 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' @@ -8,7 +9,7 @@ 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' @@ -24,10 +25,18 @@ export interface OpenAPIGeneratorGenerateOptions extends Partial 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 + /** * Common schemas to be used for $ref resolution. */ @@ -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 { - 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[] = [] diff --git a/packages/server/src/adapters/standard/rpc-handler.ts b/packages/server/src/adapters/standard/rpc-handler.ts index b7dd9db08..177657825 100644 --- a/packages/server/src/adapters/standard/rpc-handler.ts +++ b/packages/server/src/adapters/standard/rpc-handler.ts @@ -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 extends StandardHandlerOptions, StandardRPCJsonSerializerOptions { +export interface StandardRPCHandlerOptions + extends StandardHandlerOptions, StandardRPCJsonSerializerOptions, StandardRPCMatcherOptions { } export class StandardRPCHandler extends StandardHandler { constructor(router: Router, options: StandardRPCHandlerOptions = {}) { 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) diff --git a/packages/server/src/adapters/standard/rpc-matcher.test.ts b/packages/server/src/adapters/standard/rpc-matcher.test.ts index 63c3a56b5..264ed9258 100644 --- a/packages/server/src/adapters/standard/rpc-matcher.test.ts +++ b/packages/server/src/adapters/standard/rpc-matcher.test.ts @@ -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 + }, + }) + 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, + }) + }) }) diff --git a/packages/server/src/adapters/standard/rpc-matcher.ts b/packages/server/src/adapters/standard/rpc-matcher.ts index e35d9d3c8..abe347ba1 100644 --- a/packages/server/src/adapters/standard/rpc-matcher.ts +++ b/packages/server/src/adapters/standard/rpc-matcher.ts @@ -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 +} + export class StandardRPCMatcher implements StandardMatcher { + private readonly filter: Exclude + private readonly tree: Record< HTTPPath, { @@ -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)) { diff --git a/packages/server/src/router-utils.ts b/packages/server/src/router-utils.ts index 217299d27..6e2ff79e1 100644 --- a/packages/server/src/router-utils.ts +++ b/packages/server/src/router-utils.ts @@ -165,7 +165,7 @@ export interface TraverseContractProceduresOptions { } export interface ContractProcedureCallbackOptions { - contract: AnyContractProcedure + contract: AnyContractProcedure | AnyProcedure path: readonly string[] }