From a276addef87417b91faf2a4f71427f17e62314da Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 4 Sep 2025 15:08:44 +0530 Subject: [PATCH] feat(cli): add openapi suppport in orpc (#563) --- apps/cli/src/constants.ts | 2 + apps/cli/src/helpers/core/api-setup.ts | 9 +++- .../cli/src/helpers/core/post-installation.ts | 9 ++++ .../deployment/alchemy/alchemy-next-setup.ts | 1 - .../deployment/alchemy/alchemy-nuxt-setup.ts | 1 - .../alchemy/alchemy-react-router-setup.ts | 1 - .../deployment/alchemy/alchemy-solid-setup.ts | 1 - .../alchemy/alchemy-svelte-setup.ts | 1 - .../alchemy/alchemy-tanstack-router-setup.ts | 1 - .../alchemy/alchemy-tanstack-start-setup.ts | 1 - .../next/src/app/rpc/[...all]/route.ts.hbs | 35 +++++++++++++-- .../backend/server/elysia/src/index.ts.hbs | 33 +++++++++++++- .../backend/server/express/src/index.ts.hbs | 42 +++++++++++++++-- .../backend/server/fastify/src/index.ts.hbs | 35 ++++++++++++++- .../backend/server/hono/src/index.ts.hbs | 45 ++++++++++++++++--- 15 files changed, 193 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index ea89bd5..1189e3c 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -112,6 +112,8 @@ export const dependencyVersionMap = { "@orpc/server": "^1.8.6", "@orpc/client": "^1.8.6", + "@orpc/openapi": "^1.8.6", + "@orpc/zod": "^1.8.6", "@orpc/tanstack-query": "^1.8.6", "@trpc/tanstack-react-query": "^11.5.0", diff --git a/apps/cli/src/helpers/core/api-setup.ts b/apps/cli/src/helpers/core/api-setup.ts index 1c9ca0e..f685ac3 100644 --- a/apps/cli/src/helpers/core/api-setup.ts +++ b/apps/cli/src/helpers/core/api-setup.ts @@ -54,7 +54,14 @@ function getApiDependencies( > = {}; if (api === "orpc") { - deps.server = { dependencies: ["@orpc/server", "@orpc/client"] }; + deps.server = { + dependencies: [ + "@orpc/server", + "@orpc/client", + "@orpc/openapi", + "@orpc/zod", + ], + }; } else if (api === "trpc") { deps.server = { dependencies: ["@trpc/server", "@trpc/client"] }; } diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts index 720f75c..1d75f56 100644 --- a/apps/cli/src/helpers/core/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -14,6 +14,7 @@ export async function displayPostInstallInstructions( config: ProjectConfig & { depsInstalled: boolean }, ) { const { + api, database, relativePath, packageManager, @@ -158,6 +159,14 @@ export async function displayPostInstallInstructions( if (!isConvex) { output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`; + + if (api === "orpc") { + if (backend === "next") { + output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:3000/rpc/api\n`; + } else { + output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:3000/api\n`; + } + } } if (addons?.includes("starlight")) { diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts index df5e8a4..86b4ecc 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts @@ -25,7 +25,6 @@ export async function setupNextAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } await fs.writeJson(pkgPath, pkg, { spaces: 2 }); diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts index 409df11..f92011c 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts @@ -26,7 +26,6 @@ export async function setupNuxtAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } await fs.writeJson(pkgPath, pkg, { spaces: 2 }); diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts index b3dd582..f422ce1 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts @@ -25,7 +25,6 @@ export async function setupReactRouterAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } await fs.writeJson(pkgPath, pkg, { spaces: 2 }); diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts index 5b542c0..a99fba8 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts @@ -25,7 +25,6 @@ export async function setupSolidAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } await fs.writeJson(pkgPath, pkg, { spaces: 2 }); diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts index 1d12471..08dade7 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts @@ -26,7 +26,6 @@ export async function setupSvelteAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts index 14140f9..529f9f1 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts @@ -25,7 +25,6 @@ export async function setupTanStackRouterAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts index 832eb26..be88ac1 100644 --- a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts @@ -26,7 +26,6 @@ export async function setupTanStackStartAlchemyDeploy( ...pkg.scripts, deploy: "alchemy deploy", destroy: "alchemy destroy", - dev: "alchemy dev", }; } diff --git a/apps/cli/templates/api/orpc/server/next/src/app/rpc/[...all]/route.ts.hbs b/apps/cli/templates/api/orpc/server/next/src/app/rpc/[...all]/route.ts.hbs index 5250db4..fd0559b 100644 --- a/apps/cli/templates/api/orpc/server/next/src/app/rpc/[...all]/route.ts.hbs +++ b/apps/cli/templates/api/orpc/server/next/src/app/rpc/[...all]/route.ts.hbs @@ -2,18 +2,47 @@ import { createContext } from '@/lib/context' {{/if}} import { appRouter } from '@/routers' +import { OpenAPIHandler } from '@orpc/openapi/fetch' +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' import { RPCHandler } from '@orpc/server/fetch' +import { onError } from '@orpc/server' import { NextRequest } from 'next/server' -const handler = new RPCHandler(appRouter) +const rpcHandler = new RPCHandler(appRouter, { + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) +const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) async function handleRequest(req: NextRequest) { - const { response } = await handler.handle(req, { + const rpcResult = await rpcHandler.handle(req, { prefix: '/rpc', context: {{#if (eq auth "better-auth")}}await createContext(req){{else}}{}{{/if}}, }) + if (rpcResult.response) return rpcResult.response - return response ?? new Response('Not found', { status: 404 }) + const apiResult = await apiHandler.handle(req, { + prefix: '/rpc/api', + context: {{#if (eq auth "better-auth")}}await createContext(req){{else}}{}{{/if}}, + }) + if (apiResult.response) return apiResult.response + + return new Response('Not found', { status: 404 }) } export const GET = handleRequest diff --git a/apps/cli/templates/backend/server/elysia/src/index.ts.hbs b/apps/cli/templates/backend/server/elysia/src/index.ts.hbs index 10b9a9f..853b0c2 100644 --- a/apps/cli/templates/backend/server/elysia/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/elysia/src/index.ts.hbs @@ -10,7 +10,11 @@ import { appRouter } from "./routers/index"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; {{/if}} {{#if (eq api "orpc")}} +import { OpenAPIHandler } from "@orpc/openapi/fetch"; +import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { RPCHandler } from "@orpc/server/fetch"; +import { onError } from "@orpc/server"; import { appRouter } from "./routers"; import { createContext } from "./lib/context"; {{/if}} @@ -19,7 +23,25 @@ import { auth } from "./lib/auth"; {{/if}} {{#if (eq api "orpc")}} -const handler = new RPCHandler(appRouter); +const rpcHandler = new RPCHandler(appRouter, { + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); +const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); {{/if}} {{#if (eq runtime "node")}} @@ -48,12 +70,19 @@ const app = new Elysia() {{/if}} {{#if (eq api "orpc")}} .all('/rpc*', async (context) => { - const { response } = await handler.handle(context.request, { + const { response } = await rpcHandler.handle(context.request, { prefix: '/rpc', context: await createContext({ context }) }) return response ?? new Response('Not Found', { status: 404 }) }) + .all('/api*', async (context) => { + const { response } = await apiHandler.handle(context.request, { + prefix: '/api', + context: await createContext({ context }) + }) + return response ?? new Response('Not Found', { status: 404 }) + }) {{/if}} {{#if (eq api "trpc")}} .all("/trpc/*", async (context) => { diff --git a/apps/cli/templates/backend/server/express/src/index.ts.hbs b/apps/cli/templates/backend/server/express/src/index.ts.hbs index 2a269e4..43cfa74 100644 --- a/apps/cli/templates/backend/server/express/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/express/src/index.ts.hbs @@ -5,7 +5,11 @@ import { createContext } from "./lib/context"; import { appRouter } from "./routers/index"; {{/if}} {{#if (eq api "orpc")}} +import { OpenAPIHandler } from "@orpc/openapi/node"; +import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { RPCHandler } from "@orpc/server/node"; +import { onError } from "@orpc/server"; import { appRouter } from "./routers"; {{#if (eq auth "better-auth")}} import { createContext } from "./lib/context"; @@ -50,9 +54,28 @@ app.use( {{/if}} {{#if (eq api "orpc")}} -const handler = new RPCHandler(appRouter); -app.use("/rpc{*path}", async (req, res, next) => { - const { matched } = await handler.handle(req, res, { +const rpcHandler = new RPCHandler(appRouter, { + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); +const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +app.use(async (req, res, next) => { + const rpcResult = await rpcHandler.handle(req, res, { prefix: "/rpc", {{#if (eq auth "better-auth")}} context: await createContext({ req }), @@ -60,7 +83,18 @@ app.use("/rpc{*path}", async (req, res, next) => { context: {}, {{/if}} }); - if (matched) return; + if (rpcResult.matched) return; + + const apiResult = await apiHandler.handle(req, res, { + prefix: "/api", + {{#if (eq auth "better-auth")}} + context: await createContext({ req }), + {{else}} + context: {}, + {{/if}} + }); + if (apiResult.matched) return; + next(); }); {{/if}} diff --git a/apps/cli/templates/backend/server/fastify/src/index.ts.hbs b/apps/cli/templates/backend/server/fastify/src/index.ts.hbs index 936ed93..b247f27 100644 --- a/apps/cli/templates/backend/server/fastify/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/fastify/src/index.ts.hbs @@ -9,8 +9,12 @@ import { appRouter, type AppRouter } from "./routers/index"; {{/if}} {{#if (eq api "orpc")}} +import { OpenAPIHandler } from "@orpc/openapi/node"; +import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { RPCHandler } from "@orpc/server/node"; import { CORSPlugin } from "@orpc/server/plugins"; +import { onError } from "@orpc/server"; import { appRouter } from "./routers/index"; import { createServer } from "node:http"; {{#if (eq auth "better-auth")}} @@ -40,7 +44,7 @@ const baseCorsConfig = { }; {{#if (eq api "orpc")}} -const handler = new RPCHandler(appRouter, { +const rpcHandler = new RPCHandler(appRouter, { plugins: [ new CORSPlugin({ origin: process.env.CORS_ORIGIN, @@ -48,13 +52,31 @@ const handler = new RPCHandler(appRouter, { allowHeaders: ["Content-Type", "Authorization"], }), ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], }); const fastify = Fastify({ logger: true, serverFactory: (fastifyHandler) => { const server = createServer(async (req, res) => { - const { matched } = await handler.handle(req, res, { + const { matched } = await rpcHandler.handle(req, res, { context: await createContext(req.headers), prefix: "/rpc", }); @@ -63,6 +85,15 @@ const fastify = Fastify({ return; } + const apiResult = await apiHandler.handle(req, res, { + context: await createContext(req.headers), + prefix: "/api", + }); + + if (apiResult.matched) { + return; + } + fastifyHandler(req, res); }); diff --git a/apps/cli/templates/backend/server/hono/src/index.ts.hbs b/apps/cli/templates/backend/server/hono/src/index.ts.hbs index 33a8589..a5392a8 100644 --- a/apps/cli/templates/backend/server/hono/src/index.ts.hbs +++ b/apps/cli/templates/backend/server/hono/src/index.ts.hbs @@ -5,7 +5,11 @@ import "dotenv/config"; import { env } from "cloudflare:workers"; {{/if}} {{#if (eq api "orpc")}} +import { OpenAPIHandler } from "@orpc/openapi/fetch"; +import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { RPCHandler } from "@orpc/server/fetch"; +import { onError } from "@orpc/server"; import { createContext } from "./lib/context"; import { appRouter } from "./routers/index"; {{/if}} @@ -54,17 +58,48 @@ app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); {{/if}} {{#if (eq api "orpc")}} -const handler = new RPCHandler(appRouter); -app.use("/rpc/*", async (c, next) => { +export const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +export const rpcHandler = new RPCHandler(appRouter, { + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +app.use("/*", async (c, next) => { const context = await createContext({ context: c }); - const { matched, response } = await handler.handle(c.req.raw, { + + const rpcResult = await rpcHandler.handle(c.req.raw, { prefix: "/rpc", context: context, }); - if (matched) { - return c.newResponse(response.body, response); + if (rpcResult.matched) { + return c.newResponse(rpcResult.response.body, rpcResult.response); } + + const apiResult = await apiHandler.handle(c.req.raw, { + prefix: "/api", + context: context, + }); + + if (apiResult.matched) { + return c.newResponse(apiResult.response.body, apiResult.response); + } + await next(); }); {{/if}}