Skip to content

Commit 4e59295

Browse files
authored
feat(openapi): add allow option to OpenAPIReferenceHandlerPlugin (#1911)
Adds an `allow` option to `OpenAPIReferenceHandlerPlugin` so the docs UI and OpenAPI spec can be served conditionally, for example only to authenticated users. When it resolves to `false`, the request falls through as unmatched, as if the plugin were not installed, so unauthorized clients cannot tell the docs and spec paths exist. Resolves #1907 ## Design - `allow: Value<Promisable<boolean>, [StandardHandlerRoutingInterceptorOptions<T>]>`, defaulting to `true`. It receives the same options as `spec`, `docsTitle`, and `docsHead`, so decisions can use the handler context or request headers. - Evaluated only after a docs/spec path matches, so no check runs on unrelated requests. ## Testing - Denied requests return the unmatched result for both paths without generating the spec, and the predicate receives the routing interceptor options. - The predicate is never called for unrelated paths; behavior is unchanged when the option is omitted. - Docs gain a "Restricting Access" section on the plugin page.
1 parent 416b6bc commit 4e59295

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

apps/content/docs/plugins/openapi-reference.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ npm install swagger-ui @types/swagger-ui
8383
You can also load custom assets for the docs UI by setting `providerScriptUrl` and `providerCssUrl`.
8484
:::
8585

86+
## Restricting Access
87+
88+
By default, the docs UI and OpenAPI specification are publicly accessible. Use `allow` to serve them conditionally. When it resolves to `false`, the request falls through as unmatched, as if the plugin were not installed.
89+
90+
```ts
91+
const handler = new OpenAPIHandler(router, {
92+
plugins: [
93+
new OpenAPIReferenceHandlerPlugin({
94+
spec: () => generator.generate(router),
95+
allow: async ({ context }) => context.user !== undefined,
96+
}),
97+
]
98+
})
99+
```
100+
86101
## Learn More
87102

88103
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/plugins/openapi-reference.ts).

packages/openapi/src/plugins/openapi-reference.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,44 @@ describe('openAPIReferenceHandlerPlugin', () => {
119119
expect(spec).not.toHaveBeenCalled()
120120
})
121121

122+
it('returns the unmatched result when allow resolves to false', async () => {
123+
const spec = vi.fn().mockResolvedValue(createSpec())
124+
const allow = vi.fn().mockResolvedValue(false)
125+
const plugin = new OpenAPIReferenceHandlerPlugin({ spec, allow })
126+
const { interceptor } = getInterceptor(plugin)
127+
const nextResult = { matched: false as const }
128+
129+
for (const url of ['/', '/spec.json'] as const) {
130+
const { result } = await invoke(interceptor, { url, nextResult })
131+
132+
expect(result).toBe(nextResult)
133+
}
134+
135+
expect(allow).toHaveBeenCalledTimes(2)
136+
expect(allow).toHaveBeenCalledWith(expect.objectContaining({
137+
context: {},
138+
request: expect.objectContaining({ url: '/spec.json' }),
139+
}))
140+
expect(spec).not.toHaveBeenCalled()
141+
})
142+
143+
it('serves normally when allow resolves to true, without calling it for unrelated paths', async () => {
144+
const spec = vi.fn().mockResolvedValue(createSpec())
145+
const allow = vi.fn().mockResolvedValue(true)
146+
const plugin = new OpenAPIReferenceHandlerPlugin({ spec, allow })
147+
const { interceptor } = getInterceptor(plugin)
148+
149+
await invoke(interceptor, { url: '/not-found' })
150+
151+
expect(allow).not.toHaveBeenCalled()
152+
153+
const { result } = await invoke(interceptor, { url: '/spec.json' })
154+
155+
expect(allow).toHaveBeenCalledOnce()
156+
expect(result.matched).toBe(true)
157+
expect(result.response?.status).toBe(200)
158+
})
159+
122160
it('serves the OpenAPI spec file from a custom spec path with a runtime prefix', async () => {
123161
const specDocument = createSpec('Generated API')
124162
const spec = vi.fn().mockResolvedValue(specDocument)

packages/openapi/src/plugins/openapi-reference.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ export interface OpenAPIReferenceHandlerPluginOptions<T extends Context, TProvid
2525
*/
2626
spec: Value<Promisable<OpenAPIDocument>, [StandardHandlerRoutingInterceptorOptions<T>]>
2727

28+
/**
29+
* Determines whether the docs UI and OpenAPI JSON are allowed to be served for a request.
30+
* When it resolves to `false`, the request falls through as unmatched,
31+
* as if the plugin were not installed. Useful for restricting access
32+
* to authenticated users.
33+
*/
34+
allow?: Value<Promisable<boolean>, [StandardHandlerRoutingInterceptorOptions<T>]>
35+
2836
/**
2937
* The URL path at which to serve the OpenAPI JSON.
3038
*
@@ -102,6 +110,7 @@ export class OpenAPIReferenceHandlerPlugin<
102110
name = '~openapi-reference'
103111

104112
private readonly spec: OpenAPIReferenceHandlerPluginOptions<T, TProvider>['spec']
113+
private readonly allow: OpenAPIReferenceHandlerPluginOptions<T, TProvider>['allow']
105114
private readonly specPath: Exclude<OpenAPIReferenceHandlerPluginOptions<T, TProvider>['specPath'], undefined>
106115
private readonly provider: Exclude<OpenAPIReferenceHandlerPluginOptions<T, TProvider>['provider'], undefined>
107116
private readonly providerConfig: OpenAPIReferenceHandlerPluginOptions<T, TProvider>['providerConfig']
@@ -113,6 +122,7 @@ export class OpenAPIReferenceHandlerPlugin<
113122

114123
constructor(options: OpenAPIReferenceHandlerPluginOptions<T, TProvider>) {
115124
this.spec = options.spec
125+
this.allow = options.allow
116126
this.specPath = options.specPath ?? '/spec.json'
117127
this.provider = options.provider ?? 'scalar' as TProvider
118128
this.providerConfig = options.providerConfig
@@ -150,6 +160,10 @@ export class OpenAPIReferenceHandlerPlugin<
150160
return result
151161
}
152162

163+
if (await value(this.allow, routingInterceptorOptions) === false) {
164+
return result
165+
}
166+
153167
const span = getOpenTelemetryConfig()?.trace.getActiveSpan()
154168
const spec = await value(this.spec, routingInterceptorOptions)
155169

0 commit comments

Comments
 (0)