From 7f48805ca41dbd94eb1b8f5a83f68a50e969a2b7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Dec 2025 14:05:14 +0100 Subject: [PATCH] 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 --- bun.lock | 1 + jest.config.js | 4 +-- package.json | 1 + src/node/orpc/server.ts | 80 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 7e1786ecaf..6bc875edba 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^1.2.5", "@orpc/client": "^1.11.3", + "@orpc/openapi": "^1.12.2", "@orpc/server": "^1.11.3", "@orpc/zod": "^1.11.3", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/jest.config.js b/jest.config.js index d6ea97b249..f831bd2763 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,8 +18,8 @@ module.exports = { transform: { "^.+\\.(ts|tsx|js|mjs)$": ["babel-jest"], }, - // Transform ESM modules (like shiki, @orpc) to CommonJS for Jest - transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki)/)"], + // Transform ESM modules to CommonJS for Jest + transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"], // Run tests in parallel (use 50% of available cores, or 4 minimum) maxWorkers: "50%", // Force exit after tests complete to avoid hanging on lingering handles diff --git a/package.json b/package.json index 84886417dd..a0396313d7 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@mozilla/readability": "^0.6.0", "@openrouter/ai-sdk-provider": "^1.2.5", "@orpc/client": "^1.11.3", + "@orpc/openapi": "^1.12.2", "@orpc/server": "^1.11.3", "@orpc/zod": "^1.11.3", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index ea2d1cacdd..e2c3172917 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -13,6 +13,9 @@ import { WebSocketServer } from "ws"; import { RPCHandler } from "@orpc/server/node"; import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws"; import { onError } from "@orpc/server"; +import { OpenAPIGenerator } from "@orpc/openapi"; +import { OpenAPIHandler } from "@orpc/openapi/node"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { router, type AppRouter } from "@/node/orpc/router"; import type { ORPCContext } from "@/node/orpc/context"; import { extractWsHeaders } from "@/node/orpc/authMiddleware"; @@ -53,6 +56,10 @@ export interface OrpcServer { baseUrl: string; /** WebSocket URL for WS connections */ wsUrl: string; + /** URL for OpenAPI spec JSON */ + specUrl: string; + /** URL for Scalar API docs */ + docsUrl: string; /** Close the server and cleanup resources */ close: () => Promise; } @@ -100,6 +107,77 @@ export async function createOrpcServer({ const orpcRouter = existingRouter ?? router(authToken); + // OpenAPI generator for spec endpoint + const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }); + + // OpenAPI spec endpoint + app.get("/api/spec.json", async (_req, res) => { + const spec = await openAPIGenerator.generate(orpcRouter, { + info: { + title: "Mux API", + version: VERSION.git_describe, + description: "API for Mux", + }, + servers: [{ url: "/api" }], + security: authToken ? [{ bearerAuth: [] }] : undefined, + components: authToken + ? { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + }, + }, + } + : undefined, + }); + res.json(spec); + }); + + // Scalar API reference UI + app.get("/api/docs", (_req, res) => { + const html = ` + + + mux API Reference + + + + +
+ + + +`; + res.setHeader("Content-Type", "text/html"); + res.send(html); + }); + + // OpenAPI REST handler (for Scalar/OpenAPI clients) + const openAPIHandler = new OpenAPIHandler(orpcRouter, { + interceptors: [onError(onOrpcError)], + }); + + app.use("/api", async (req, res, next) => { + // Skip spec.json and docs routes - they're handled above + if (req.path === "/spec.json" || req.path === "/docs") { + return next(); + } + const { matched } = await openAPIHandler.handle(req, res, { + prefix: "/api", + context: { ...context, headers: req.headers }, + }); + if (matched) return; + next(); + }); + // oRPC HTTP handler const orpcHandler = new RPCHandler(orpcRouter, { interceptors: [onError(onOrpcError)], @@ -161,6 +239,8 @@ export async function createOrpcServer({ port: actualPort, baseUrl: `http://${connectableHost}:${actualPort}`, wsUrl: `ws://${connectableHost}:${actualPort}/orpc/ws`, + specUrl: `http://${connectableHost}:${actualPort}/api/spec.json`, + docsUrl: `http://${connectableHost}:${actualPort}/api/docs`, close: async () => { // Close WebSocket server first wsServer.close();