Skip to content

Commit c0834a4

Browse files
committed
feat: add OpenAPI spec and Scalar API docs endpoints
Add OpenAPI handler at /api for REST-style clients alongside existing oRPC handler. Includes: - /api/spec.json - OpenAPI specification - /api/docs - Scalar API reference UI - /api/* - OpenAPIHandler for REST requests
1 parent 23b6375 commit c0834a4

File tree

3 files changed

+82
-0
lines changed

3 files changed

+82
-0
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@mozilla/readability": "^0.6.0",
1616
"@openrouter/ai-sdk-provider": "^1.2.5",
1717
"@orpc/client": "^1.11.3",
18+
"@orpc/openapi": "^1.12.2",
1819
"@orpc/server": "^1.11.3",
1920
"@orpc/zod": "^1.11.3",
2021
"@radix-ui/react-checkbox": "^1.3.3",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@mozilla/readability": "^0.6.0",
5656
"@openrouter/ai-sdk-provider": "^1.2.5",
5757
"@orpc/client": "^1.11.3",
58+
"@orpc/openapi": "^1.12.2",
5859
"@orpc/server": "^1.11.3",
5960
"@orpc/zod": "^1.11.3",
6061
"@radix-ui/react-checkbox": "^1.3.3",

src/node/orpc/server.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { WebSocketServer } from "ws";
1313
import { RPCHandler } from "@orpc/server/node";
1414
import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws";
1515
import { onError } from "@orpc/server";
16+
import { OpenAPIGenerator } from "@orpc/openapi";
17+
import { OpenAPIHandler } from "@orpc/openapi/node";
18+
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
1619
import { router, type AppRouter } from "@/node/orpc/router";
1720
import type { ORPCContext } from "@/node/orpc/context";
1821
import { extractWsHeaders } from "@/node/orpc/authMiddleware";
@@ -53,6 +56,10 @@ export interface OrpcServer {
5356
baseUrl: string;
5457
/** WebSocket URL for WS connections */
5558
wsUrl: string;
59+
/** URL for OpenAPI spec JSON */
60+
specUrl: string;
61+
/** URL for Scalar API docs */
62+
docsUrl: string;
5663
/** Close the server and cleanup resources */
5764
close: () => Promise<void>;
5865
}
@@ -100,6 +107,77 @@ export async function createOrpcServer({
100107

101108
const orpcRouter = existingRouter ?? router(authToken);
102109

110+
// OpenAPI generator for spec endpoint
111+
const openAPIGenerator = new OpenAPIGenerator({
112+
schemaConverters: [new ZodToJsonSchemaConverter()],
113+
});
114+
115+
// OpenAPI spec endpoint
116+
app.get("/api/spec.json", async (_req, res) => {
117+
const spec = await openAPIGenerator.generate(orpcRouter, {
118+
info: {
119+
title: "Mux API",
120+
version: VERSION.git_describe,
121+
description: "API for Mux",
122+
},
123+
servers: [{ url: "/api" }],
124+
security: authToken ? [{ bearerAuth: [] }] : undefined,
125+
components: authToken
126+
? {
127+
securitySchemes: {
128+
bearerAuth: {
129+
type: "http",
130+
scheme: "bearer",
131+
},
132+
},
133+
}
134+
: undefined,
135+
});
136+
res.json(spec);
137+
});
138+
139+
// Scalar API reference UI
140+
app.get("/api/docs", (_req, res) => {
141+
const html = `<!doctype html>
142+
<html>
143+
<head>
144+
<title>mux API Reference</title>
145+
<meta charset="utf-8" />
146+
<meta name="viewport" content="width=device-width, initial-scale=1" />
147+
</head>
148+
<body>
149+
<div id="app"></div>
150+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
151+
<script>
152+
Scalar.createApiReference('#app', {
153+
url: '/api/spec.json',
154+
${authToken ? "authentication: { securitySchemes: { bearerAuth: { token: '' } } }," : ""}
155+
})
156+
</script>
157+
</body>
158+
</html>`;
159+
res.setHeader("Content-Type", "text/html");
160+
res.send(html);
161+
});
162+
163+
// OpenAPI REST handler (for Scalar/OpenAPI clients)
164+
const openAPIHandler = new OpenAPIHandler(orpcRouter, {
165+
interceptors: [onError(onOrpcError)],
166+
});
167+
168+
app.use("/api", async (req, res, next) => {
169+
// Skip spec.json and docs routes - they're handled above
170+
if (req.path === "/spec.json" || req.path === "/docs") {
171+
return next();
172+
}
173+
const { matched } = await openAPIHandler.handle(req, res, {
174+
prefix: "/api",
175+
context: { ...context, headers: req.headers },
176+
});
177+
if (matched) return;
178+
next();
179+
});
180+
103181
// oRPC HTTP handler
104182
const orpcHandler = new RPCHandler(orpcRouter, {
105183
interceptors: [onError(onOrpcError)],
@@ -161,6 +239,8 @@ export async function createOrpcServer({
161239
port: actualPort,
162240
baseUrl: `http://${connectableHost}:${actualPort}`,
163241
wsUrl: `ws://${connectableHost}:${actualPort}/orpc/ws`,
242+
specUrl: `http://${connectableHost}:${actualPort}/api/spec.json`,
243+
docsUrl: `http://${connectableHost}:${actualPort}/api/docs`,
164244
close: async () => {
165245
// Close WebSocket server first
166246
wsServer.close();

0 commit comments

Comments
 (0)