Skip to content

Commit 7336c81

Browse files
authored
feat(openapi): OpenAPI Reference Plugin (#442)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a plugin to serve OpenAPI specification JSON and an interactive API reference UI directly from the API route, with customizable paths and UI options. - Added documentation for the new OpenAPI Reference Plugin, including setup instructions and usage examples. - **Improvements** - Playground projects (Next.js, Nuxt, SolidStart, SvelteKit, contract-first) now serve the API reference and OpenAPI spec from the main API route, replacing custom `/scalar` and `/spec` endpoints. - Sidebar navigation updated to include a direct link to the OpenAPI Reference documentation. - **Bug Fixes** - Ensured plugin initialization receives both configuration options and the router instance for improved extensibility. - **Documentation** - Updated and added guides to reflect the new API reference integration and recommend the simplified setup. - **Chores** - Removed obsolete routes and files related to the old API reference and spec endpoints in all playgrounds. - Updated links and button texts throughout playgrounds to reference the new API route. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4c2a250 commit 7336c81

File tree

30 files changed

+484
-378
lines changed

30 files changed

+484
-378
lines changed

apps/content/.vitepress/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export default defineConfig({
184184
text: 'Plugins',
185185
collapsed: true,
186186
items: [
187+
{ text: 'OpenAPI Reference (Swagger)', link: '/docs/openapi/plugins/openapi-reference' },
187188
{ text: 'Zod Smart Coercion', link: '/docs/openapi/plugins/zod-smart-coercion' },
188189
],
189190
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
title: OpenAPI Reference Plugin (Swagger/Scalar)
3+
description: A plugin that serves API reference documentation and the OpenAPI specification for your API.
4+
---
5+
6+
# OpenAPI Reference Plugin (Swagger/Scalar)
7+
8+
This plugin provides API reference documentation powered by [Scalar](https://github.com/scalar/scalar), along with the OpenAPI specification in JSON format.
9+
10+
::: info
11+
This plugin relies on the [OpenAPI Generator](/docs/openapi/openapi-specification). Please review its documentation before using this plugin.
12+
:::
13+
14+
## Setup
15+
16+
```ts
17+
import { ZodToJsonSchemaConverter } from '@orpc/zod'
18+
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
19+
20+
const handler = new OpenAPIHandler(router, {
21+
plugins: [
22+
new OpenAPIReferencePlugin({
23+
schemaConverters: [
24+
new ZodToJsonSchemaConverter(),
25+
],
26+
specGenerateOptions: {
27+
info: {
28+
title: 'ORPC Playground',
29+
version: '1.0.0',
30+
},
31+
},
32+
}),
33+
]
34+
})
35+
```
36+
37+
::: info
38+
By default, the API reference client is served at the root path (`/`), and the OpenAPI specification is available at `/spec.json`. You can customize these paths by providing the `docsPath` and `specPath` options.
39+
:::

apps/content/docs/openapi/scalar.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ description: Create a beautiful API client for your oRPC effortlessly.
77

88
Leverage the [OpenAPI Specification](/docs/openapi/openapi-specification) to generate a stunning API client for your oRPC using [Scalar](https://github.com/scalar/scalar).
99

10+
::: info
11+
This guide covers the basics. For a simpler setup, consider using the [OpenAPI Reference Plugin](/docs/openapi/plugins/openapi-reference), which serves both the API reference UI and the OpenAPI specification.
12+
:::
13+
1014
## Basic Example
1115

1216
```ts

packages/openapi/package.json

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"import": "./dist/index.mjs",
2121
"default": "./dist/index.mjs"
2222
},
23+
"./plugins": {
24+
"types": "./dist/plugins/index.d.mts",
25+
"import": "./dist/plugins/index.mjs",
26+
"default": "./dist/plugins/index.mjs"
27+
},
2328
"./standard": {
2429
"types": "./dist/adapters/standard/index.d.mts",
2530
"import": "./dist/adapters/standard/index.mjs",
@@ -39,6 +44,7 @@
3944
},
4045
"exports": {
4146
".": "./src/index.ts",
47+
"./plugins": "./src/plugins/index.ts",
4248
"./standard": "./src/adapters/standard/index.ts",
4349
"./fetch": "./src/adapters/fetch/index.ts",
4450
"./node": "./src/adapters/node/index.ts"

packages/openapi/src/openapi-generator.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOp
2121
schemaConverters?: ConditionalSchemaConverter[]
2222
}
2323

24+
export interface OpenAPIGeneratorGenerateOptions extends Partial<Omit<OpenAPI.Document, 'openapi'>> {}
25+
2426
/**
2527
* The generator that converts oRPC routers/contracts to OpenAPI specifications.
2628
*
@@ -40,9 +42,12 @@ export class OpenAPIGenerator {
4042
*
4143
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-specification OpenAPI Specification Docs}
4244
*/
43-
async generate(router: AnyContractRouter | AnyRouter, base: Omit<OpenAPI.Document, 'openapi'>): Promise<OpenAPI.Document> {
44-
const doc: OpenAPI.Document = clone(base) as OpenAPI.Document
45-
doc.openapi = '3.1.1'
45+
async generate(router: AnyContractRouter | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise<OpenAPI.Document> {
46+
const doc: OpenAPI.Document = {
47+
...clone(options),
48+
info: options.info ?? { title: 'API Reference', version: '0.0.0' },
49+
openapi: '3.1.1',
50+
} as OpenAPI.Document
4651

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

packages/openapi/src/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './openapi-reference'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { os } from '@orpc/server'
2+
import { z } from 'zod'
3+
import { ZodToJsonSchemaConverter } from '../../../zod/src'
4+
import { OpenAPIHandler } from '../adapters/fetch/openapi-handler'
5+
import { OpenAPIGenerator } from '../openapi-generator'
6+
import { OpenAPIReferencePlugin } from './openapi-reference'
7+
8+
describe('openAPIReferencePlugin', () => {
9+
const jsonSchemaConverter = new ZodToJsonSchemaConverter()
10+
const generator = new OpenAPIGenerator({
11+
schemaConverters: [jsonSchemaConverter],
12+
})
13+
const router = { ping: os.input(z.object({ name: z.string() })).handler(() => 'pong') }
14+
15+
it('serve docs and spec endpoints', async () => {
16+
const handler = new OpenAPIHandler(router, {
17+
plugins: [
18+
new OpenAPIReferencePlugin({
19+
schemaConverters: [jsonSchemaConverter],
20+
}),
21+
],
22+
})
23+
24+
const { response } = await handler.handle(new Request('http://localhost:3000'))
25+
26+
expect(response!.status).toBe(200)
27+
expect(response!.headers.get('content-type')).toBe('text/html')
28+
expect(await response!.text()).toContain('<title>API Reference</title>')
29+
30+
const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json'))
31+
32+
expect(specResponse!.status).toBe(200)
33+
expect(specResponse!.headers.get('content-type')).toBe('application/json')
34+
expect(await specResponse!.json()).toEqual({
35+
...await generator.generate(router),
36+
servers: [{ url: 'http://localhost:3000/' }],
37+
})
38+
39+
expect(
40+
await handler.handle(new Request('http://localhost:3000/not_found')),
41+
).toEqual({ matched: false })
42+
})
43+
44+
it('serve docs and spec endpoints with prefix', async () => {
45+
const handler = new OpenAPIHandler(router, {
46+
plugins: [
47+
new OpenAPIReferencePlugin({
48+
schemaConverters: [jsonSchemaConverter],
49+
}),
50+
],
51+
})
52+
53+
const { response } = await handler.handle(new Request('http://localhost:3000/api'), {
54+
prefix: '/api',
55+
})
56+
57+
expect(response!.status).toBe(200)
58+
expect(response!.headers.get('content-type')).toBe('text/html')
59+
expect(await response!.text()).toContain('<title>API Reference</title>')
60+
61+
const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/api/spec.json'), {
62+
prefix: '/api',
63+
})
64+
65+
expect(specResponse!.status).toBe(200)
66+
expect(specResponse!.headers.get('content-type')).toBe('application/json')
67+
expect(await specResponse!.json()).toEqual({
68+
...await generator.generate(router),
69+
servers: [{ url: 'http://localhost:3000/api' }],
70+
})
71+
72+
expect(
73+
await handler.handle(new Request('http://localhost:3000'), {
74+
prefix: '/api',
75+
}),
76+
).toEqual({ matched: false })
77+
78+
expect(
79+
await handler.handle(new Request('http://localhost:3000/spec.json'), {
80+
prefix: '/api',
81+
}),
82+
).toEqual({ matched: false })
83+
84+
expect(
85+
await handler.handle(new Request('http://localhost:3000/api/not_found'), {
86+
prefix: '/api',
87+
}),
88+
).toEqual({ matched: false })
89+
})
90+
91+
it('not serve docs and spec endpoints if procedure matched', async () => {
92+
const router = {
93+
ping: os.route({ method: 'GET', path: '/' }).handler(() => 'pong'),
94+
pong: os.route({ method: 'GET', path: '/spec.json' }).handler(() => 'ping'),
95+
}
96+
97+
const handler = new OpenAPIHandler(router, {
98+
plugins: [
99+
new OpenAPIReferencePlugin({
100+
schemaConverters: [jsonSchemaConverter],
101+
}),
102+
],
103+
})
104+
105+
const { response } = await handler.handle(new Request('http://localhost:3000'))
106+
expect(await response!.json()).toEqual('pong')
107+
108+
const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json'))
109+
expect(await specResponse!.json()).toEqual('ping')
110+
111+
const { matched } = await handler.handle(new Request('http://localhost:3000/not_found'))
112+
expect(matched).toBe(false)
113+
})
114+
115+
it('with config', async () => {
116+
const handler = new OpenAPIHandler(router, {
117+
plugins: [
118+
new OpenAPIReferencePlugin({
119+
schemaConverters: [jsonSchemaConverter],
120+
docsConfig: async () => ({ foo: '__SOME_VALUE__' }),
121+
}),
122+
],
123+
})
124+
125+
const { response } = await handler.handle(new Request('http://localhost:3000'))
126+
127+
expect(response!.status).toBe(200)
128+
expect(response!.headers.get('content-type')).toBe('text/html')
129+
expect(await response!.text()).toContain('__SOME_VALUE__')
130+
})
131+
})

0 commit comments

Comments
 (0)