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
1 change: 1 addition & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export default defineConfig({
text: 'Plugins',
collapsed: true,
items: [
{ text: 'OpenAPI Reference (Swagger)', link: '/docs/openapi/plugins/openapi-reference' },
{ text: 'Zod Smart Coercion', link: '/docs/openapi/plugins/zod-smart-coercion' },
],
},
Expand Down
39 changes: 39 additions & 0 deletions apps/content/docs/openapi/plugins/openapi-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: OpenAPI Reference Plugin (Swagger/Scalar)
description: A plugin that serves API reference documentation and the OpenAPI specification for your API.
---

# OpenAPI Reference Plugin (Swagger/Scalar)

This plugin provides API reference documentation powered by [Scalar](https://github.com/scalar/scalar), along with the OpenAPI specification in JSON format.

::: info
This plugin relies on the [OpenAPI Generator](/docs/openapi/openapi-specification). Please review its documentation before using this plugin.
:::

## Setup

```ts
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'

const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
specGenerateOptions: {
info: {
title: 'ORPC Playground',
version: '1.0.0',
},
},
}),
]
})
```

::: info
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.
:::
4 changes: 4 additions & 0 deletions apps/content/docs/openapi/scalar.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ description: Create a beautiful API client for your oRPC effortlessly.

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

::: info
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.
:::

## Basic Example

```ts
Expand Down
6 changes: 6 additions & 0 deletions packages/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./plugins": {
"types": "./dist/plugins/index.d.mts",
"import": "./dist/plugins/index.mjs",
"default": "./dist/plugins/index.mjs"
},
"./standard": {
"types": "./dist/adapters/standard/index.d.mts",
"import": "./dist/adapters/standard/index.mjs",
Expand All @@ -39,6 +44,7 @@
},
"exports": {
".": "./src/index.ts",
"./plugins": "./src/plugins/index.ts",
"./standard": "./src/adapters/standard/index.ts",
"./fetch": "./src/adapters/fetch/index.ts",
"./node": "./src/adapters/node/index.ts"
Expand Down
11 changes: 8 additions & 3 deletions packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOp
schemaConverters?: ConditionalSchemaConverter[]
}

export interface OpenAPIGeneratorGenerateOptions extends Partial<Omit<OpenAPI.Document, 'openapi'>> {}

/**
* The generator that converts oRPC routers/contracts to OpenAPI specifications.
*
Expand All @@ -40,9 +42,12 @@ export class OpenAPIGenerator {
*
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-specification OpenAPI Specification Docs}
*/
async generate(router: AnyContractRouter | AnyRouter, base: Omit<OpenAPI.Document, 'openapi'>): Promise<OpenAPI.Document> {
const doc: OpenAPI.Document = clone(base) as OpenAPI.Document
doc.openapi = '3.1.1'
async generate(router: AnyContractRouter | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise<OpenAPI.Document> {
const doc: OpenAPI.Document = {
...clone(options),
info: options.info ?? { title: 'API Reference', version: '0.0.0' },
openapi: '3.1.1',
} as OpenAPI.Document

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

Expand Down
1 change: 1 addition & 0 deletions packages/openapi/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './openapi-reference'
131 changes: 131 additions & 0 deletions packages/openapi/src/plugins/openapi-reference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { os } from '@orpc/server'
import { z } from 'zod'
import { ZodToJsonSchemaConverter } from '../../../zod/src'
import { OpenAPIHandler } from '../adapters/fetch/openapi-handler'
import { OpenAPIGenerator } from '../openapi-generator'
import { OpenAPIReferencePlugin } from './openapi-reference'

describe('openAPIReferencePlugin', () => {
const jsonSchemaConverter = new ZodToJsonSchemaConverter()
const generator = new OpenAPIGenerator({
schemaConverters: [jsonSchemaConverter],
})
const router = { ping: os.input(z.object({ name: z.string() })).handler(() => 'pong') }

it('serve docs and spec endpoints', async () => {
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [jsonSchemaConverter],
}),
],
})

const { response } = await handler.handle(new Request('http://localhost:3000'))

expect(response!.status).toBe(200)
expect(response!.headers.get('content-type')).toBe('text/html')
expect(await response!.text()).toContain('<title>API Reference</title>')

const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json'))

expect(specResponse!.status).toBe(200)
expect(specResponse!.headers.get('content-type')).toBe('application/json')
expect(await specResponse!.json()).toEqual({
...await generator.generate(router),
servers: [{ url: 'http://localhost:3000/' }],
})

expect(
await handler.handle(new Request('http://localhost:3000/not_found')),
).toEqual({ matched: false })
})

it('serve docs and spec endpoints with prefix', async () => {
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [jsonSchemaConverter],
}),
],
})

const { response } = await handler.handle(new Request('http://localhost:3000/api'), {
prefix: '/api',
})

expect(response!.status).toBe(200)
expect(response!.headers.get('content-type')).toBe('text/html')
expect(await response!.text()).toContain('<title>API Reference</title>')

const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/api/spec.json'), {
prefix: '/api',
})

expect(specResponse!.status).toBe(200)
expect(specResponse!.headers.get('content-type')).toBe('application/json')
expect(await specResponse!.json()).toEqual({
...await generator.generate(router),
servers: [{ url: 'http://localhost:3000/api' }],
})

expect(
await handler.handle(new Request('http://localhost:3000'), {
prefix: '/api',
}),
).toEqual({ matched: false })

expect(
await handler.handle(new Request('http://localhost:3000/spec.json'), {
prefix: '/api',
}),
).toEqual({ matched: false })

expect(
await handler.handle(new Request('http://localhost:3000/api/not_found'), {
prefix: '/api',
}),
).toEqual({ matched: false })
})

it('not serve docs and spec endpoints if procedure matched', async () => {
const router = {
ping: os.route({ method: 'GET', path: '/' }).handler(() => 'pong'),
pong: os.route({ method: 'GET', path: '/spec.json' }).handler(() => 'ping'),
}

const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [jsonSchemaConverter],
}),
],
})

const { response } = await handler.handle(new Request('http://localhost:3000'))
expect(await response!.json()).toEqual('pong')

const { response: specResponse } = await handler.handle(new Request('http://localhost:3000/spec.json'))
expect(await specResponse!.json()).toEqual('ping')

const { matched } = await handler.handle(new Request('http://localhost:3000/not_found'))
expect(matched).toBe(false)
})

it('with config', async () => {
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [jsonSchemaConverter],
docsConfig: async () => ({ foo: '__SOME_VALUE__' }),
}),
],
})

const { response } = await handler.handle(new Request('http://localhost:3000'))

expect(response!.status).toBe(200)
expect(response!.headers.get('content-type')).toBe('text/html')
expect(await response!.text()).toContain('__SOME_VALUE__')
})
})
Loading