From 0100c2afc42cd3f18ef48804d3acf51076710714 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 09:03:49 +0700 Subject: [PATCH 01/10] standardHandlerPlugin now accept router as 2nh param --- packages/server/src/adapters/standard/handler.test.ts | 2 +- packages/server/src/adapters/standard/handler.ts | 2 +- packages/server/src/adapters/standard/plugin.test.ts | 9 +++++---- packages/server/src/adapters/standard/plugin.ts | 9 +++++---- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/server/src/adapters/standard/handler.test.ts b/packages/server/src/adapters/standard/handler.test.ts index 828b5b243..eebf00f24 100644 --- a/packages/server/src/adapters/standard/handler.test.ts +++ b/packages/server/src/adapters/standard/handler.test.ts @@ -337,7 +337,7 @@ describe('standardHandler', () => { const handler = new StandardHandler(router, matcher, codec, options) expect(init).toHaveBeenCalledOnce() - expect(init).toHaveBeenCalledWith(options) + expect(init).toHaveBeenCalledWith(options, router) }) describe('prefix', () => { diff --git a/packages/server/src/adapters/standard/handler.ts b/packages/server/src/adapters/standard/handler.ts index 80748e33a..74a09e7e3 100644 --- a/packages/server/src/adapters/standard/handler.ts +++ b/packages/server/src/adapters/standard/handler.ts @@ -60,7 +60,7 @@ export class StandardHandler { ) { const plugins = new CompositeStandardHandlerPlugin(options.plugins) - plugins.init(options) + plugins.init(options, router) this.interceptors = toArray(options.interceptors) this.clientInterceptors = toArray(options.clientInterceptors) diff --git a/packages/server/src/adapters/standard/plugin.test.ts b/packages/server/src/adapters/standard/plugin.test.ts index 75c2bd861..94ba16734 100644 --- a/packages/server/src/adapters/standard/plugin.test.ts +++ b/packages/server/src/adapters/standard/plugin.test.ts @@ -20,16 +20,17 @@ describe('compositeStandardHandlerPlugin', () => { const interceptor = vi.fn() const options = { interceptors: [interceptor] } + const router = { ping: { pong: {} } } - compositePlugin.init(options) + compositePlugin.init(options, router) expect(plugin1.init).toHaveBeenCalledOnce() expect(plugin2.init).toHaveBeenCalledOnce() expect(plugin3.init).toHaveBeenCalledOnce() - expect(plugin1.init.mock.calls[0]![0]).toBe(options) - expect(plugin2.init.mock.calls[0]![0]).toBe(options) - expect(plugin3.init.mock.calls[0]![0]).toBe(options) + expect(plugin1.init).toHaveBeenCalledWith(options, router) + expect(plugin2.init).toHaveBeenCalledWith(options, router) + expect(plugin3.init).toHaveBeenCalledWith(options, router) expect(plugin3.init).toHaveBeenCalledBefore(plugin2.init) expect(plugin2.init).toHaveBeenCalledBefore(plugin1.init) diff --git a/packages/server/src/adapters/standard/plugin.ts b/packages/server/src/adapters/standard/plugin.ts index 1836ec9a9..af424f3be 100644 --- a/packages/server/src/adapters/standard/plugin.ts +++ b/packages/server/src/adapters/standard/plugin.ts @@ -1,9 +1,10 @@ import type { Context } from '../../context' +import type { Router } from '../../router' import type { StandardHandlerOptions } from './handler' -export interface StandardHandlerPlugin { +export interface StandardHandlerPlugin { order?: number - init?(options: StandardHandlerOptions): void + init?(options: StandardHandlerOptions, router: Router): void } export class CompositeStandardHandlerPlugin> implements StandardHandlerPlugin { @@ -13,9 +14,9 @@ export class CompositeStandardHandlerPlugin (a.order ?? 0) - (b.order ?? 0)) } - init(options: StandardHandlerOptions): void { + init(options: StandardHandlerOptions, router: Router): void { for (const plugin of this.plugins) { - plugin.init?.(options) + plugin.init?.(options, router) } } } From da4ddb16e9f9a9fc54bd90024e7e4b4b529fb3ce Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 10:50:14 +0700 Subject: [PATCH 02/10] wip --- packages/openapi/package.json | 6 + packages/openapi/src/openapi-generator.ts | 11 +- packages/openapi/src/plugins/index.ts | 1 + packages/openapi/src/plugins/scalar.ts | 168 ++++++++++++++++++ playgrounds/contract-first/src/main.ts | 93 +++------- playgrounds/nextjs/src/app/actions.ts | 2 +- .../nextjs/src/app/api/[[...rest]]/route.ts | 23 ++- playgrounds/nextjs/src/app/page.tsx | 2 +- playgrounds/nextjs/src/app/scalar/route.ts | 35 ---- playgrounds/nextjs/src/app/spec/route.ts | 34 ---- playgrounds/nuxt/app.vue | 2 +- playgrounds/nuxt/server/routes/api/[...].ts | 23 ++- playgrounds/nuxt/server/routes/scalar.ts | 37 ---- playgrounds/nuxt/server/routes/spec.ts | 30 ---- .../solid-start/src/routes/api/[...rest].ts | 25 ++- playgrounds/solid-start/src/routes/index.tsx | 2 +- playgrounds/solid-start/src/routes/scalar.ts | 42 ----- playgrounds/solid-start/src/routes/spec.ts | 31 ---- .../svelte-kit/src/routes/+page.svelte | 2 +- .../src/routes/api/[...rest]/+server.ts | 23 ++- .../svelte-kit/src/routes/scalar/+server.ts | 42 ----- .../svelte-kit/src/routes/spec/+server.ts | 35 ---- 22 files changed, 301 insertions(+), 368 deletions(-) create mode 100644 packages/openapi/src/plugins/index.ts create mode 100644 packages/openapi/src/plugins/scalar.ts delete mode 100644 playgrounds/nextjs/src/app/scalar/route.ts delete mode 100644 playgrounds/nextjs/src/app/spec/route.ts delete mode 100644 playgrounds/nuxt/server/routes/scalar.ts delete mode 100644 playgrounds/nuxt/server/routes/spec.ts delete mode 100644 playgrounds/solid-start/src/routes/scalar.ts delete mode 100644 playgrounds/solid-start/src/routes/spec.ts delete mode 100644 playgrounds/svelte-kit/src/routes/scalar/+server.ts delete mode 100644 playgrounds/svelte-kit/src/routes/spec/+server.ts diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 046fb7737..39a10182f 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -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", @@ -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" diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index b377bb6a1..3302a995b 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -21,6 +21,8 @@ export interface OpenAPIGeneratorOptions extends StandardOpenAPIJsonSerializerOp schemaConverters?: ConditionalSchemaConverter[] } +export interface OpenAPIGeneratorGenerateOptions extends Partial> {} + /** * The generator that converts oRPC routers/contracts to OpenAPI specifications. * @@ -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): Promise { - const doc: OpenAPI.Document = clone(base) as OpenAPI.Document - doc.openapi = '3.1.1' + async generate(router: AnyContractRouter | AnyRouter, options: OpenAPIGeneratorGenerateOptions = {}): Promise { + 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[] }[] = [] diff --git a/packages/openapi/src/plugins/index.ts b/packages/openapi/src/plugins/index.ts new file mode 100644 index 000000000..f31bcd743 --- /dev/null +++ b/packages/openapi/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './scalar' diff --git a/packages/openapi/src/plugins/scalar.ts b/packages/openapi/src/plugins/scalar.ts new file mode 100644 index 000000000..82c2611d7 --- /dev/null +++ b/packages/openapi/src/plugins/scalar.ts @@ -0,0 +1,168 @@ +import type { Context, HTTPPath, Router } from '@orpc/server' +import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { OpenAPIGeneratorGenerateOptions, OpenAPIGeneratorOptions } from '../openapi-generator' +import { standardizeHTTPPath } from '@orpc/openapi-client/standard' +import { stringifyJSON } from '@orpc/shared' +import { OpenAPIGenerator } from '../openapi-generator' + +export interface ScalarApiReferencePluginOptions extends OpenAPIGeneratorOptions { + /** + * Options to pass to the OpenAPI generate. + * + */ + specGenerateOptions?: OpenAPIGeneratorGenerateOptions + + /** + * The URL path at which to serve the OpenAPI JSON. + * @default '/spec.json' + */ + specPath?: HTTPPath + + /** + * The URL path at which to serve the API reference UI. + * @default '/' + */ + docsPath?: HTTPPath + + /** + * The document title for the API reference UI. + * @default 'API Reference' + */ + docsTitle?: string + + /** + * Arbitrary configuration object for the UI. + */ + docsConfig?: object + + /** + * HTML to inject into the of the docs page. + */ + docsHead?: string + + /** + * URL of the external script bundle for the reference UI. + * @default 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' + */ + docsScriptUrl?: string + + /** + * Override function to generate the full HTML for the docs page. + */ + renderDocsHtml?: ( + specUrl: string, + title: string, + head: string, + scriptUrl: string, + config?: object + ) => string +} + +export class ScalarApiReferencePlugin implements StandardHandlerPlugin { + private readonly generator: OpenAPIGenerator + private readonly specGenerateOptions: ScalarApiReferencePluginOptions['specGenerateOptions'] + private readonly specPath: Exclude + private readonly docsPath: Exclude + private readonly docsTitle: Exclude + private readonly docsHead: Exclude + private readonly docsScriptUrl: Exclude + private readonly docsConfig?: ScalarApiReferencePluginOptions['docsConfig'] + private readonly renderDocsHtml: NonNullable + + constructor(options: ScalarApiReferencePluginOptions = {}) { + this.specGenerateOptions = options.specGenerateOptions + this.docsPath = options.docsPath ?? '/' + this.docsTitle = options.docsTitle ?? 'API Reference' + this.docsConfig = options.docsConfig + this.docsScriptUrl = options.docsScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' + this.docsHead = options.docsHead ?? '' + this.specPath = options.specPath ?? '/spec.json' + this.generator = new OpenAPIGenerator(options) + + this.renderDocsHtml = options.renderDocsHtml ?? this.defaultRenderHtml.bind(this) + } + + init(options: StandardHandlerOptions, router: Router): void { + options.interceptors ??= [] + let spec: Awaited> + + options.interceptors.push(async (options) => { + const res = await options.next() + + if (res.matched) { + return res + } + + const prefix = options.prefix ?? '' + const docsUrl = new URL(standardizeHTTPPath(`${prefix}${this.docsPath}`), options.request.url.origin) + const specUrl = new URL(standardizeHTTPPath(`${prefix}${this.specPath}`), options.request.url.origin) + + if (options.request.url.pathname === docsUrl.pathname) { + const html = this.renderDocsHtml( + specUrl.toString(), + this.docsTitle, + this.docsHead, + this.docsScriptUrl, + this.docsConfig, + ) + + return { + matched: true, + response: { + status: 200, + headers: {}, + body: new File([html], 'api-reference.html', { type: 'text/html' }), + }, + } + } + + if (options.request.url.pathname === specUrl.pathname) { + spec ??= await this.generator.generate(router, { + servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], + ...this.specGenerateOptions, + }) + + return { + matched: true, + response: { + status: 200, + headers: {}, + body: new File([stringifyJSON(spec)], 'spec.json', { type: 'application/json' }), + }, + } + } + + return res + }) + } + + private defaultRenderHtml( + specUrl: string, + title: string, + head: string, + scriptUrl: string, + config?: object, + ): string { + const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') + + return ` + + + + + + ${esc(title)} + ${head} + + + + + + + ` + } +} diff --git a/playgrounds/contract-first/src/main.ts b/playgrounds/contract-first/src/main.ts index 6cd633be7..5ce932278 100644 --- a/playgrounds/contract-first/src/main.ts +++ b/playgrounds/contract-first/src/main.ts @@ -1,11 +1,10 @@ import { createServer } from 'node:http' -import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' -import { contract } from './contract' import { router } from './router' +import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' import './polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -16,6 +15,26 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), + new ScalarApiReferencePlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + specGenerateOptions: { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + }, + }), ], }) @@ -27,12 +46,6 @@ const rpcHandler = new RPCHandler(router, { ], }) -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], -}) - const server = createServer(async (req, res) => { const context = req.headers.authorization ? { user: { id: 'test', name: 'John Doe', email: 'john@doe.com' } } @@ -56,68 +69,10 @@ const server = createServer(async (req, res) => { return } - if (req.url === '/spec.json') { - const spec = await openAPIGenerator.generate(contract, { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [ - { url: '/api' /** Should use absolute URLs in production */ }, - ], - security: [{ bearerAuth: [] }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }) - - res.writeHead(200, { - 'Content-Type': 'application/json', - }) - res.end(JSON.stringify(spec)) - return - } - - const html = ` - - - - ORPC Playground - - - - - - - - - - -` - - res.writeHead(200, { - 'Content-Type': 'text/html', - }) - res.end(html) + res.statusCode = 404 + res.end('Not found') }) server.listen(3000, () => { - console.log('Playground is available at http://localhost:3000') + console.log('Playground is available at http://localhost:3000/api') }) diff --git a/playgrounds/nextjs/src/app/actions.ts b/playgrounds/nextjs/src/app/actions.ts index 7d230a88e..0bb660bdd 100644 --- a/playgrounds/nextjs/src/app/actions.ts +++ b/playgrounds/nextjs/src/app/actions.ts @@ -31,7 +31,7 @@ const dosomething = pub export const redirectToScalarForm = createFormAction(dosomething, { interceptors: [ onSuccess(async () => { - redirect('/scalar') + redirect('/api') }), ], }) diff --git a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts index 46cce08e6..6bc3df6e7 100644 --- a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts +++ b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts @@ -1,7 +1,8 @@ import { router } from '@/router' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { onError } from '@orpc/server' -import { ZodSmartCoercionPlugin } from '@orpc/zod' +import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -12,6 +13,26 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), + new ScalarApiReferencePlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + specGenerateOptions: { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + }, + }), ], }) diff --git a/playgrounds/nextjs/src/app/page.tsx b/playgrounds/nextjs/src/app/page.tsx index b06a074d5..118df15c9 100644 --- a/playgrounds/nextjs/src/app/page.tsx +++ b/playgrounds/nextjs/src/app/page.tsx @@ -12,7 +12,7 @@ export default function Home() {
- +
{' '} page. diff --git a/playgrounds/nextjs/src/app/scalar/route.ts b/playgrounds/nextjs/src/app/scalar/route.ts deleted file mode 100644 index 0d977bd95..000000000 --- a/playgrounds/nextjs/src/app/scalar/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -const html = ` - - - - ORPC Playground - - - - - - - - - -` - -export function GET() { - return new Response(html, { - headers: { - 'Content-Type': 'text/html', - }, - }) -} diff --git a/playgrounds/nextjs/src/app/spec/route.ts b/playgrounds/nextjs/src/app/spec/route.ts deleted file mode 100644 index 118c5b1f7..000000000 --- a/playgrounds/nextjs/src/app/spec/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { router } from '@/router' -import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], -}) - -export async function GET(request: Request) { - const spec = await openAPIGenerator.generate(router, { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [{ url: '/api' /** Should use absolute URLs in production */ }], - security: [{ bearerAuth: [] }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }) - - return new Response(JSON.stringify(spec), { - headers: { - 'Content-Type': 'application/json', - }, - }) -} diff --git a/playgrounds/nuxt/app.vue b/playgrounds/nuxt/app.vue index ebf5b2fd8..f2f323acd 100644 --- a/playgrounds/nuxt/app.vue +++ b/playgrounds/nuxt/app.vue @@ -4,7 +4,7 @@

You can visit the {' '} - Scalar API Reference + Scalar API Reference {' '} page.

diff --git a/playgrounds/nuxt/server/routes/api/[...].ts b/playgrounds/nuxt/server/routes/api/[...].ts index e652385de..29cbb1902 100644 --- a/playgrounds/nuxt/server/routes/api/[...].ts +++ b/playgrounds/nuxt/server/routes/api/[...].ts @@ -1,7 +1,8 @@ import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' -import { ZodSmartCoercionPlugin } from '@orpc/zod' +import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '~/server/router' +import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -11,6 +12,26 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), + new ScalarApiReferencePlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + specGenerateOptions: { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + }, + }), ], }) diff --git a/playgrounds/nuxt/server/routes/scalar.ts b/playgrounds/nuxt/server/routes/scalar.ts deleted file mode 100644 index f069d5da2..000000000 --- a/playgrounds/nuxt/server/routes/scalar.ts +++ /dev/null @@ -1,37 +0,0 @@ -export default defineEventHandler(async (event) => { - const html = ` - - - - ORPC Playground - - - - - - - - - - - - ` - - setResponseHeader(event, 'content-type', 'text/html') - return html -}) diff --git a/playgrounds/nuxt/server/routes/spec.ts b/playgrounds/nuxt/server/routes/spec.ts deleted file mode 100644 index 17ee1f250..000000000 --- a/playgrounds/nuxt/server/routes/spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' -import { router } from '~/server/router' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], -}) - -export default defineEventHandler(async (event) => { - const spec = await openAPIGenerator.generate(router, { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [{ url: '/api' /** Should use absolute URLs in production */ }], - security: [{ bearerAuth: [] }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }) - - return spec -}) diff --git a/playgrounds/solid-start/src/routes/api/[...rest].ts b/playgrounds/solid-start/src/routes/api/[...rest].ts index b56cde8f7..05fdf65b9 100644 --- a/playgrounds/solid-start/src/routes/api/[...rest].ts +++ b/playgrounds/solid-start/src/routes/api/[...rest].ts @@ -1,9 +1,10 @@ import type { APIEvent } from '@solidjs/start/server' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from '~/router' -import { ZodSmartCoercionPlugin } from '@orpc/zod' -import '~/polyfill' +import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { onError } from '@orpc/server' +import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import '~/polyfill' const handler = new OpenAPIHandler(router, { interceptors: [ @@ -13,6 +14,26 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), + new ScalarApiReferencePlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + specGenerateOptions: { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + }, + }), ], }) diff --git a/playgrounds/solid-start/src/routes/index.tsx b/playgrounds/solid-start/src/routes/index.tsx index 92dbe18ad..cf5af529d 100644 --- a/playgrounds/solid-start/src/routes/index.tsx +++ b/playgrounds/solid-start/src/routes/index.tsx @@ -8,7 +8,7 @@ export default function Home() {

You can visit the {' '} - Scalar API Reference + Scalar API Reference {' '} page.

diff --git a/playgrounds/solid-start/src/routes/scalar.ts b/playgrounds/solid-start/src/routes/scalar.ts deleted file mode 100644 index 6dad78648..000000000 --- a/playgrounds/solid-start/src/routes/scalar.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { APIEvent } from '@solidjs/start/server' - -export async function GET(event: APIEvent) { - const html = ` - - - - ORPC Playground - - - - - - - - - - - - ` - - return new Response(html, { - headers: { - 'content-type': 'text/html', - }, - }) -} diff --git a/playgrounds/solid-start/src/routes/spec.ts b/playgrounds/solid-start/src/routes/spec.ts deleted file mode 100644 index aca4d1dfb..000000000 --- a/playgrounds/solid-start/src/routes/spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' -import type { APIEvent } from '@solidjs/start/server' -import { router } from '~/router' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], -}) - -export async function GET(event: APIEvent) { - const spec = await openAPIGenerator.generate(router, { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [{ url: '/api' /** Should use absolute URLs in production */ }], - security: [{ bearerAuth: [] }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }) - - return spec -} diff --git a/playgrounds/svelte-kit/src/routes/+page.svelte b/playgrounds/svelte-kit/src/routes/+page.svelte index 4dbd72dea..7a92ba1ef 100644 --- a/playgrounds/svelte-kit/src/routes/+page.svelte +++ b/playgrounds/svelte-kit/src/routes/+page.svelte @@ -12,7 +12,7 @@

You can visit the {' '} - Scalar API Reference + Scalar API Reference {' '} page.

diff --git a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts index 61ec36773..804e94539 100644 --- a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts +++ b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts @@ -2,7 +2,8 @@ import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from '../../../router' import { onError } from '@orpc/server' import type { RequestHandler } from '@sveltejs/kit' -import { ZodSmartCoercionPlugin } from '@orpc/zod' +import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' +import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const handler = new OpenAPIHandler(router, { @@ -13,6 +14,26 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), + new ScalarApiReferencePlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + specGenerateOptions: { + info: { + title: 'ORPC Playground', + version: '1.0.0', + }, + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + }, + }), ], }) diff --git a/playgrounds/svelte-kit/src/routes/scalar/+server.ts b/playgrounds/svelte-kit/src/routes/scalar/+server.ts deleted file mode 100644 index 363b56653..000000000 --- a/playgrounds/svelte-kit/src/routes/scalar/+server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' - -export const GET: RequestHandler = async ({ request }) => { - const html = ` - - - - ORPC Playground - - - - - - - - - - - - ` - - return new Response(html, { - headers: { - 'content-type': 'text/html', - }, - }) -} diff --git a/playgrounds/svelte-kit/src/routes/spec/+server.ts b/playgrounds/svelte-kit/src/routes/spec/+server.ts deleted file mode 100644 index 40f449a06..000000000 --- a/playgrounds/svelte-kit/src/routes/spec/+server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { OpenAPIGenerator } from '@orpc/openapi' -import { ZodToJsonSchemaConverter } from '@orpc/zod' -import type { RequestHandler } from '@sveltejs/kit' -import { router } from '../../router' - -const openAPIGenerator = new OpenAPIGenerator({ - schemaConverters: [ - new ZodToJsonSchemaConverter(), - ], -}) - -export const GET: RequestHandler = async ({ request }) => { - const spec = await openAPIGenerator.generate(router, { - info: { - title: 'ORPC Playground', - version: '1.0.0', - }, - servers: [{ url: '/api' /** Should use absolute URLs in production */ }], - security: [{ bearerAuth: [] }], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }) - - return new Response(JSON.stringify(spec), { - headers: { - 'Content-Type': 'application/json', - }, - }) -} From 89262a73d65ef98319942162288f2a0a28a18d18 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 11:01:19 +0700 Subject: [PATCH 03/10] improve --- .../plugins/{scalar.ts => api-reference.ts} | 86 ++++++++----------- packages/openapi/src/plugins/index.ts | 2 +- 2 files changed, 39 insertions(+), 49 deletions(-) rename packages/openapi/src/plugins/{scalar.ts => api-reference.ts} (58%) diff --git a/packages/openapi/src/plugins/scalar.ts b/packages/openapi/src/plugins/api-reference.ts similarity index 58% rename from packages/openapi/src/plugins/scalar.ts rename to packages/openapi/src/plugins/api-reference.ts index 82c2611d7..a98f6204c 100644 --- a/packages/openapi/src/plugins/scalar.ts +++ b/packages/openapi/src/plugins/api-reference.ts @@ -1,11 +1,10 @@ import type { Context, HTTPPath, Router } from '@orpc/server' import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' import type { OpenAPIGeneratorGenerateOptions, OpenAPIGeneratorOptions } from '../openapi-generator' -import { standardizeHTTPPath } from '@orpc/openapi-client/standard' import { stringifyJSON } from '@orpc/shared' import { OpenAPIGenerator } from '../openapi-generator' -export interface ScalarApiReferencePluginOptions extends OpenAPIGeneratorOptions { +export interface ApiReferencePluginOptions extends OpenAPIGeneratorOptions { /** * Options to pass to the OpenAPI generate. * @@ -58,18 +57,18 @@ export interface ScalarApiReferencePluginOptions extends OpenAPIGeneratorOptions ) => string } -export class ScalarApiReferencePlugin implements StandardHandlerPlugin { +export class ApiReferencePlugin implements StandardHandlerPlugin { private readonly generator: OpenAPIGenerator - private readonly specGenerateOptions: ScalarApiReferencePluginOptions['specGenerateOptions'] - private readonly specPath: Exclude - private readonly docsPath: Exclude - private readonly docsTitle: Exclude - private readonly docsHead: Exclude - private readonly docsScriptUrl: Exclude - private readonly docsConfig?: ScalarApiReferencePluginOptions['docsConfig'] - private readonly renderDocsHtml: NonNullable - - constructor(options: ScalarApiReferencePluginOptions = {}) { + private readonly specGenerateOptions: ApiReferencePluginOptions['specGenerateOptions'] + private readonly specPath: Exclude + private readonly docsPath: Exclude + private readonly docsTitle: Exclude + private readonly docsHead: Exclude + private readonly docsScriptUrl: Exclude + private readonly docsConfig?: ApiReferencePluginOptions['docsConfig'] + private readonly renderDocsHtml: NonNullable + + constructor(options: ApiReferencePluginOptions = {}) { this.specGenerateOptions = options.specGenerateOptions this.docsPath = options.docsPath ?? '/' this.docsTitle = options.docsTitle ?? 'API Reference' @@ -79,7 +78,27 @@ export class ScalarApiReferencePlugin implements StandardHand this.specPath = options.specPath ?? '/spec.json' this.generator = new OpenAPIGenerator(options) - this.renderDocsHtml = options.renderDocsHtml ?? this.defaultRenderHtml.bind(this) + const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') + + this.renderDocsHtml = options.renderDocsHtml ?? ((specUrl, title, head, scriptUrl, config) => ` + + + + + + ${esc(title)} + ${head} + + + + + + + `) } init(options: StandardHandlerOptions, router: Router): void { @@ -94,10 +113,11 @@ export class ScalarApiReferencePlugin implements StandardHand } const prefix = options.prefix ?? '' - const docsUrl = new URL(standardizeHTTPPath(`${prefix}${this.docsPath}`), options.request.url.origin) - const specUrl = new URL(standardizeHTTPPath(`${prefix}${this.specPath}`), options.request.url.origin) + const requestPathname = options.request.url.pathname.replace(/\/$/, '') + const docsUrl = new URL(`${prefix}${this.docsPath}`.replace(/\/$/, ''), options.request.url.origin) + const specUrl = new URL(`${prefix}${this.specPath}`.replace(/\/$/, ''), options.request.url.origin) - if (options.request.url.pathname === docsUrl.pathname) { + if (requestPathname === docsUrl.pathname) { const html = this.renderDocsHtml( specUrl.toString(), this.docsTitle, @@ -116,7 +136,7 @@ export class ScalarApiReferencePlugin implements StandardHand } } - if (options.request.url.pathname === specUrl.pathname) { + if (requestPathname === specUrl.pathname) { spec ??= await this.generator.generate(router, { servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], ...this.specGenerateOptions, @@ -135,34 +155,4 @@ export class ScalarApiReferencePlugin implements StandardHand return res }) } - - private defaultRenderHtml( - specUrl: string, - title: string, - head: string, - scriptUrl: string, - config?: object, - ): string { - const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') - - return ` - - - - - - ${esc(title)} - ${head} - - - - - - - ` - } } diff --git a/packages/openapi/src/plugins/index.ts b/packages/openapi/src/plugins/index.ts index f31bcd743..810ad22f4 100644 --- a/packages/openapi/src/plugins/index.ts +++ b/packages/openapi/src/plugins/index.ts @@ -1 +1 @@ -export * from './scalar' +export * from './api-reference' From 2ea9db4ca52491f3296a8b24d10368df67b3aeb7 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 11:02:06 +0700 Subject: [PATCH 04/10] wip --- playgrounds/contract-first/src/main.ts | 4 ++-- playgrounds/nextjs/src/app/api/[[...rest]]/route.ts | 4 ++-- playgrounds/nuxt/server/routes/api/[...].ts | 4 ++-- playgrounds/solid-start/src/routes/api/[...rest].ts | 4 ++-- playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/playgrounds/contract-first/src/main.ts b/playgrounds/contract-first/src/main.ts index 5ce932278..8f73dbb9c 100644 --- a/playgrounds/contract-first/src/main.ts +++ b/playgrounds/contract-first/src/main.ts @@ -4,7 +4,7 @@ import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from './router' -import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import { ApiReferencePlugin } from '@orpc/openapi/plugins' import './polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -15,7 +15,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ScalarApiReferencePlugin({ + new ApiReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts index 6bc3df6e7..8cb0c9860 100644 --- a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts +++ b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts @@ -2,7 +2,7 @@ import { router } from '@/router' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' -import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import { ApiReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -13,7 +13,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ScalarApiReferencePlugin({ + new ApiReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/nuxt/server/routes/api/[...].ts b/playgrounds/nuxt/server/routes/api/[...].ts index 29cbb1902..3e036e856 100644 --- a/playgrounds/nuxt/server/routes/api/[...].ts +++ b/playgrounds/nuxt/server/routes/api/[...].ts @@ -2,7 +2,7 @@ import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '~/server/router' -import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import { ApiReferencePlugin } from '@orpc/openapi/plugins' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -12,7 +12,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ScalarApiReferencePlugin({ + new ApiReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/solid-start/src/routes/api/[...rest].ts b/playgrounds/solid-start/src/routes/api/[...rest].ts index 05fdf65b9..8dbbefff1 100644 --- a/playgrounds/solid-start/src/routes/api/[...rest].ts +++ b/playgrounds/solid-start/src/routes/api/[...rest].ts @@ -3,7 +3,7 @@ import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from '~/router' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { onError } from '@orpc/server' -import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import { ApiReferencePlugin } from '@orpc/openapi/plugins' import '~/polyfill' const handler = new OpenAPIHandler(router, { @@ -14,7 +14,7 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ScalarApiReferencePlugin({ + new ApiReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts index 804e94539..f59e6d6c1 100644 --- a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts +++ b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts @@ -3,7 +3,7 @@ import { router } from '../../../router' import { onError } from '@orpc/server' import type { RequestHandler } from '@sveltejs/kit' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' -import { ScalarApiReferencePlugin } from '@orpc/openapi/plugins' +import { ApiReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const handler = new OpenAPIHandler(router, { @@ -14,7 +14,7 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ScalarApiReferencePlugin({ + new ApiReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], From f4eb2d188fa8512711f1ccf1b33fc9b792fbc676 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 11:04:15 +0700 Subject: [PATCH 05/10] OpenAPIReferencePlugin --- packages/openapi/src/plugins/index.ts | 2 +- ...{api-reference.ts => openapi-reference.ts} | 24 +++++++++---------- playgrounds/contract-first/src/main.ts | 4 ++-- .../nextjs/src/app/api/[[...rest]]/route.ts | 4 ++-- playgrounds/nuxt/server/routes/api/[...].ts | 4 ++-- .../solid-start/src/routes/api/[...rest].ts | 4 ++-- .../src/routes/api/[...rest]/+server.ts | 4 ++-- 7 files changed, 23 insertions(+), 23 deletions(-) rename packages/openapi/src/plugins/{api-reference.ts => openapi-reference.ts} (81%) diff --git a/packages/openapi/src/plugins/index.ts b/packages/openapi/src/plugins/index.ts index 810ad22f4..89b47969e 100644 --- a/packages/openapi/src/plugins/index.ts +++ b/packages/openapi/src/plugins/index.ts @@ -1 +1 @@ -export * from './api-reference' +export * from './openapi-reference' diff --git a/packages/openapi/src/plugins/api-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts similarity index 81% rename from packages/openapi/src/plugins/api-reference.ts rename to packages/openapi/src/plugins/openapi-reference.ts index a98f6204c..4f3fe7002 100644 --- a/packages/openapi/src/plugins/api-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -4,7 +4,7 @@ import type { OpenAPIGeneratorGenerateOptions, OpenAPIGeneratorOptions } from '. import { stringifyJSON } from '@orpc/shared' import { OpenAPIGenerator } from '../openapi-generator' -export interface ApiReferencePluginOptions extends OpenAPIGeneratorOptions { +export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { /** * Options to pass to the OpenAPI generate. * @@ -57,18 +57,18 @@ export interface ApiReferencePluginOptions extends OpenAPIGeneratorOptions { ) => string } -export class ApiReferencePlugin implements StandardHandlerPlugin { +export class OpenAPIReferencePlugin implements StandardHandlerPlugin { private readonly generator: OpenAPIGenerator - private readonly specGenerateOptions: ApiReferencePluginOptions['specGenerateOptions'] - private readonly specPath: Exclude - private readonly docsPath: Exclude - private readonly docsTitle: Exclude - private readonly docsHead: Exclude - private readonly docsScriptUrl: Exclude - private readonly docsConfig?: ApiReferencePluginOptions['docsConfig'] - private readonly renderDocsHtml: NonNullable - - constructor(options: ApiReferencePluginOptions = {}) { + private readonly specGenerateOptions: OpenAPIReferencePluginOptions['specGenerateOptions'] + private readonly specPath: Exclude + private readonly docsPath: Exclude + private readonly docsTitle: Exclude + private readonly docsHead: Exclude + private readonly docsScriptUrl: Exclude + private readonly docsConfig?: OpenAPIReferencePluginOptions['docsConfig'] + private readonly renderDocsHtml: NonNullable + + constructor(options: OpenAPIReferencePluginOptions = {}) { this.specGenerateOptions = options.specGenerateOptions this.docsPath = options.docsPath ?? '/' this.docsTitle = options.docsTitle ?? 'API Reference' diff --git a/playgrounds/contract-first/src/main.ts b/playgrounds/contract-first/src/main.ts index 8f73dbb9c..4130d415e 100644 --- a/playgrounds/contract-first/src/main.ts +++ b/playgrounds/contract-first/src/main.ts @@ -4,7 +4,7 @@ import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from './router' -import { ApiReferencePlugin } from '@orpc/openapi/plugins' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import './polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -15,7 +15,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ApiReferencePlugin({ + new OpenAPIReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts index 8cb0c9860..67e383825 100644 --- a/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts +++ b/playgrounds/nextjs/src/app/api/[[...rest]]/route.ts @@ -2,7 +2,7 @@ import { router } from '@/router' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' -import { ApiReferencePlugin } from '@orpc/openapi/plugins' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const openAPIHandler = new OpenAPIHandler(router, { @@ -13,7 +13,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ApiReferencePlugin({ + new OpenAPIReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/nuxt/server/routes/api/[...].ts b/playgrounds/nuxt/server/routes/api/[...].ts index 3e036e856..ee2563ec3 100644 --- a/playgrounds/nuxt/server/routes/api/[...].ts +++ b/playgrounds/nuxt/server/routes/api/[...].ts @@ -2,7 +2,7 @@ import { OpenAPIHandler } from '@orpc/openapi/node' import { onError } from '@orpc/server' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '~/server/router' -import { ApiReferencePlugin } from '@orpc/openapi/plugins' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' const openAPIHandler = new OpenAPIHandler(router, { interceptors: [ @@ -12,7 +12,7 @@ const openAPIHandler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ApiReferencePlugin({ + new OpenAPIReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/solid-start/src/routes/api/[...rest].ts b/playgrounds/solid-start/src/routes/api/[...rest].ts index 8dbbefff1..1b837056c 100644 --- a/playgrounds/solid-start/src/routes/api/[...rest].ts +++ b/playgrounds/solid-start/src/routes/api/[...rest].ts @@ -3,7 +3,7 @@ import { OpenAPIHandler } from '@orpc/openapi/fetch' import { router } from '~/router' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' import { onError } from '@orpc/server' -import { ApiReferencePlugin } from '@orpc/openapi/plugins' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '~/polyfill' const handler = new OpenAPIHandler(router, { @@ -14,7 +14,7 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ApiReferencePlugin({ + new OpenAPIReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], diff --git a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts index f59e6d6c1..7808524f6 100644 --- a/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts +++ b/playgrounds/svelte-kit/src/routes/api/[...rest]/+server.ts @@ -3,7 +3,7 @@ import { router } from '../../../router' import { onError } from '@orpc/server' import type { RequestHandler } from '@sveltejs/kit' import { ZodSmartCoercionPlugin, ZodToJsonSchemaConverter } from '@orpc/zod' -import { ApiReferencePlugin } from '@orpc/openapi/plugins' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' import '../../../polyfill' const handler = new OpenAPIHandler(router, { @@ -14,7 +14,7 @@ const handler = new OpenAPIHandler(router, { ], plugins: [ new ZodSmartCoercionPlugin(), - new ApiReferencePlugin({ + new OpenAPIReferencePlugin({ schemaConverters: [ new ZodToJsonSchemaConverter(), ], From 30efe3a19b7181b55e721312997ccb1fd4112f11 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 14:00:56 +0700 Subject: [PATCH 06/10] tests --- .../src/plugins/openapi-reference.test.ts | 102 ++++++++++++++++++ .../openapi/src/plugins/openapi-reference.ts | 42 ++++---- 2 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 packages/openapi/src/plugins/openapi-reference.test.ts diff --git a/packages/openapi/src/plugins/openapi-reference.test.ts b/packages/openapi/src/plugins/openapi-reference.test.ts new file mode 100644 index 000000000..2e1e2c8e0 --- /dev/null +++ b/packages/openapi/src/plugins/openapi-reference.test.ts @@ -0,0 +1,102 @@ +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('API Reference') + + 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/' }], + }) + + const { matched } = await handler.handle(new Request('http://localhost:3000/not_found')) + + expect(matched).toBe(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('API Reference') + + 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' }], + }) + + const { matched } = await handler.handle(new Request('http://localhost:3000/api/not_found'), { + prefix: '/api', + }) + + expect(matched).toBe(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) + }) +}) diff --git a/packages/openapi/src/plugins/openapi-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts index 4f3fe7002..071a69fcf 100644 --- a/packages/openapi/src/plugins/openapi-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -53,7 +53,7 @@ export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { title: string, head: string, scriptUrl: string, - config?: object + config: object ) => string } @@ -65,14 +65,14 @@ export class OpenAPIReferencePlugin implements StandardHandle private readonly docsTitle: Exclude private readonly docsHead: Exclude private readonly docsScriptUrl: Exclude - private readonly docsConfig?: OpenAPIReferencePluginOptions['docsConfig'] + private readonly docsConfig: Exclude private readonly renderDocsHtml: NonNullable constructor(options: OpenAPIReferencePluginOptions = {}) { this.specGenerateOptions = options.specGenerateOptions this.docsPath = options.docsPath ?? '/' this.docsTitle = options.docsTitle ?? 'API Reference' - this.docsConfig = options.docsConfig + this.docsConfig = options.docsConfig ?? {} this.docsScriptUrl = options.docsScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' this.docsHead = options.docsHead ?? '' this.specPath = options.specPath ?? '/spec.json' @@ -93,7 +93,7 @@ export class OpenAPIReferencePlugin implements StandardHandle @@ -108,46 +108,46 @@ export class OpenAPIReferencePlugin implements StandardHandle options.interceptors.push(async (options) => { const res = await options.next() - if (res.matched) { + if (res.matched || options.request.method !== 'GET') { return res } const prefix = options.prefix ?? '' - const requestPathname = options.request.url.pathname.replace(/\/$/, '') + const requestPathname = options.request.url.pathname.replace(/\/$/, '') || '/' const docsUrl = new URL(`${prefix}${this.docsPath}`.replace(/\/$/, ''), options.request.url.origin) const specUrl = new URL(`${prefix}${this.specPath}`.replace(/\/$/, ''), options.request.url.origin) - if (requestPathname === docsUrl.pathname) { - const html = this.renderDocsHtml( - specUrl.toString(), - this.docsTitle, - this.docsHead, - this.docsScriptUrl, - this.docsConfig, - ) + if (requestPathname === specUrl.pathname) { + spec ??= await this.generator.generate(router, { + servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], + ...this.specGenerateOptions, + }) return { matched: true, response: { status: 200, headers: {}, - body: new File([html], 'api-reference.html', { type: 'text/html' }), + body: new File([stringifyJSON(spec)], 'spec.json', { type: 'application/json' }), }, } } - if (requestPathname === specUrl.pathname) { - spec ??= await this.generator.generate(router, { - servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], - ...this.specGenerateOptions, - }) + if (requestPathname === docsUrl.pathname) { + const html = this.renderDocsHtml( + specUrl.toString(), + this.docsTitle, + this.docsHead, + this.docsScriptUrl, + this.docsConfig, + ) return { matched: true, response: { status: 200, headers: {}, - body: new File([stringifyJSON(spec)], 'spec.json', { type: 'application/json' }), + body: new File([html], 'api-reference.html', { type: 'text/html' }), }, } } From b1ee4104a3469425ef2886ae3bf2f024e5a819b4 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 14:27:59 +0700 Subject: [PATCH 07/10] docs --- apps/content/.vitepress/config.ts | 1 + .../docs/openapi/plugins/openapi-reference.md | 39 +++++++++++++++++++ apps/content/docs/openapi/scalar.md | 4 ++ 3 files changed, 44 insertions(+) create mode 100644 apps/content/docs/openapi/plugins/openapi-reference.md diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index da4011a30..c201c2bdc 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -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' }, ], }, diff --git a/apps/content/docs/openapi/plugins/openapi-reference.md b/apps/content/docs/openapi/plugins/openapi-reference.md new file mode 100644 index 000000000..3ecc30858 --- /dev/null +++ b/apps/content/docs/openapi/plugins/openapi-reference.md @@ -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-generator). 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. +::: diff --git a/apps/content/docs/openapi/scalar.md b/apps/content/docs/openapi/scalar.md index a99201339..c75767254 100644 --- a/apps/content/docs/openapi/scalar.md +++ b/apps/content/docs/openapi/scalar.md @@ -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 From 1ba7a8ecd1a4193bf0b40943861a2ea0a446a75b Mon Sep 17 00:00:00 2001 From: unnoq Date: Sun, 20 Apr 2025 14:31:12 +0700 Subject: [PATCH 08/10] dead links --- apps/content/docs/openapi/plugins/openapi-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/openapi/plugins/openapi-reference.md b/apps/content/docs/openapi/plugins/openapi-reference.md index 3ecc30858..8b79a5957 100644 --- a/apps/content/docs/openapi/plugins/openapi-reference.md +++ b/apps/content/docs/openapi/plugins/openapi-reference.md @@ -8,7 +8,7 @@ description: A plugin that serves API reference documentation and the OpenAPI sp 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-generator). Please review its documentation before using this plugin. +This plugin relies on the [OpenAPI Generator](/docs/openapi/openapi-specification). Please review its documentation before using this plugin. ::: ## Setup From be06b5c07004162b60599cb6d19cb7d977e51a6f Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 22 Apr 2025 10:36:26 +0700 Subject: [PATCH 09/10] improve dynamic --- .../src/plugins/openapi-reference.test.ts | 45 +++++++++++--- .../openapi/src/plugins/openapi-reference.ts | 59 +++++++++++-------- 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/packages/openapi/src/plugins/openapi-reference.test.ts b/packages/openapi/src/plugins/openapi-reference.test.ts index 2e1e2c8e0..7038eefd5 100644 --- a/packages/openapi/src/plugins/openapi-reference.test.ts +++ b/packages/openapi/src/plugins/openapi-reference.test.ts @@ -36,9 +36,9 @@ describe('openAPIReferencePlugin', () => { servers: [{ url: 'http://localhost:3000/' }], }) - const { matched } = await handler.handle(new Request('http://localhost:3000/not_found')) - - expect(matched).toBe(false) + expect( + await handler.handle(new Request('http://localhost:3000/not_found')), + ).toEqual({ matched: false }) }) it('serve docs and spec endpoints with prefix', async () => { @@ -69,11 +69,23 @@ describe('openAPIReferencePlugin', () => { servers: [{ url: 'http://localhost:3000/api' }], }) - const { matched } = await handler.handle(new Request('http://localhost:3000/api/not_found'), { - prefix: '/api', - }) - - expect(matched).toBe(false) + 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 () => { @@ -99,4 +111,21 @@ describe('openAPIReferencePlugin', () => { 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__') + }) }) diff --git a/packages/openapi/src/plugins/openapi-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts index 071a69fcf..813b48e34 100644 --- a/packages/openapi/src/plugins/openapi-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -1,49 +1,56 @@ import type { Context, HTTPPath, Router } from '@orpc/server' -import type { StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardHandlerInterceptorOptions, StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { Value } from '@orpc/shared' import type { OpenAPIGeneratorGenerateOptions, OpenAPIGeneratorOptions } from '../openapi-generator' -import { stringifyJSON } from '@orpc/shared' +import { stringifyJSON, value } from '@orpc/shared' import { OpenAPIGenerator } from '../openapi-generator' -export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { +export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { /** * Options to pass to the OpenAPI generate. * */ - specGenerateOptions?: OpenAPIGeneratorGenerateOptions + specGenerateOptions?: Value]> /** * The URL path at which to serve the OpenAPI JSON. + * * @default '/spec.json' */ specPath?: HTTPPath /** * The URL path at which to serve the API reference UI. + * * @default '/' */ docsPath?: HTTPPath /** * The document title for the API reference UI. + * * @default 'API Reference' */ - docsTitle?: string + docsTitle?: Value]> /** * Arbitrary configuration object for the UI. */ - docsConfig?: object + docsConfig?: Value]> /** * HTML to inject into the of the docs page. + * + * @default '' */ - docsHead?: string + docsHead?: Value]> /** * URL of the external script bundle for the reference UI. + * * @default 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' */ - docsScriptUrl?: string + docsScriptUrl?: Value]> /** * Override function to generate the full HTML for the docs page. @@ -53,26 +60,26 @@ export interface OpenAPIReferencePluginOptions extends OpenAPIGeneratorOptions { title: string, head: string, scriptUrl: string, - config: object + config: object | undefined ) => string } export class OpenAPIReferencePlugin implements StandardHandlerPlugin { private readonly generator: OpenAPIGenerator - private readonly specGenerateOptions: OpenAPIReferencePluginOptions['specGenerateOptions'] - private readonly specPath: Exclude - private readonly docsPath: Exclude - private readonly docsTitle: Exclude - private readonly docsHead: Exclude - private readonly docsScriptUrl: Exclude - private readonly docsConfig: Exclude - private readonly renderDocsHtml: NonNullable - - constructor(options: OpenAPIReferencePluginOptions = {}) { + private readonly specGenerateOptions: OpenAPIReferencePluginOptions['specGenerateOptions'] + private readonly specPath: Exclude['specPath'], undefined> + private readonly docsPath: Exclude['docsPath'], undefined> + private readonly docsTitle: Exclude['docsTitle'], undefined> + private readonly docsHead: Exclude['docsHead'], undefined> + private readonly docsScriptUrl: Exclude['docsScriptUrl'], undefined> + private readonly docsConfig: OpenAPIReferencePluginOptions['docsConfig'] + private readonly renderDocsHtml: Exclude['renderDocsHtml'], undefined> + + constructor(options: OpenAPIReferencePluginOptions = {}) { this.specGenerateOptions = options.specGenerateOptions this.docsPath = options.docsPath ?? '/' this.docsTitle = options.docsTitle ?? 'API Reference' - this.docsConfig = options.docsConfig ?? {} + this.docsConfig = options.docsConfig ?? undefined this.docsScriptUrl = options.docsScriptUrl ?? 'https://cdn.jsdelivr.net/npm/@scalar/api-reference' this.docsHead = options.docsHead ?? '' this.specPath = options.specPath ?? '/spec.json' @@ -93,7 +100,7 @@ export class OpenAPIReferencePlugin implements StandardHandle @@ -120,7 +127,7 @@ export class OpenAPIReferencePlugin implements StandardHandle if (requestPathname === specUrl.pathname) { spec ??= await this.generator.generate(router, { servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], - ...this.specGenerateOptions, + ...await value(this.specGenerateOptions, options), }) return { @@ -136,10 +143,10 @@ export class OpenAPIReferencePlugin implements StandardHandle if (requestPathname === docsUrl.pathname) { const html = this.renderDocsHtml( specUrl.toString(), - this.docsTitle, - this.docsHead, - this.docsScriptUrl, - this.docsConfig, + await value(this.docsTitle, options), + await value(this.docsHead, options), + await value(this.docsScriptUrl, options), + await value(this.docsConfig, options), ) return { From ac29e07c2356da55712f55985c548782b08ab891 Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 22 Apr 2025 13:12:52 +0700 Subject: [PATCH 10/10] fix caching issue on dynamic options --- packages/openapi/src/plugins/openapi-reference.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/openapi/src/plugins/openapi-reference.ts b/packages/openapi/src/plugins/openapi-reference.ts index 813b48e34..1041cb4f0 100644 --- a/packages/openapi/src/plugins/openapi-reference.ts +++ b/packages/openapi/src/plugins/openapi-reference.ts @@ -110,7 +110,6 @@ export class OpenAPIReferencePlugin implements StandardHandle init(options: StandardHandlerOptions, router: Router): void { options.interceptors ??= [] - let spec: Awaited> options.interceptors.push(async (options) => { const res = await options.next() @@ -125,7 +124,7 @@ export class OpenAPIReferencePlugin implements StandardHandle const specUrl = new URL(`${prefix}${this.specPath}`.replace(/\/$/, ''), options.request.url.origin) if (requestPathname === specUrl.pathname) { - spec ??= await this.generator.generate(router, { + const spec = await this.generator.generate(router, { servers: [{ url: new URL(prefix, options.request.url.origin).toString() }], ...await value(this.specGenerateOptions, options), })