backend-trpc-openapi

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

tRPC + OpenAPI Integration

tRPC + OpenAPI 集成

Overview

概述

Generate REST endpoints and OpenAPI documentation from your tRPC routers. Get the best of both worlds: type-safe internal API with tRPC, REST/Swagger for external consumers.
Package:
trpc-to-openapi
(active fork of archived
trpc-openapi
)
Requirements: tRPC v11+, Zod
Key Benefit: Single source of truth — define once in tRPC, expose as both RPC and REST.
从你的tRPC路由器生成REST端点和OpenAPI文档。兼得两者优势:使用tRPC实现类型安全的内部API,为外部消费者提供REST/Swagger支持。
trpc-to-openapi
(已归档的
trpc-openapi
的活跃分支)
要求:tRPC v11+、Zod
核心优势:单一事实来源——在tRPC中定义一次,同时以RPC和REST方式暴露。

When to Use This Skill

何时使用该方案

Use tRPC + OpenAPI when:
  • Internal apps use tRPC, but need REST for third parties
  • Need Swagger/OpenAPI documentation
  • Mobile apps (non-React Native) need REST endpoints
  • Microservices with mixed languages need interop
  • Public API requires REST standard
Skip OpenAPI layer when:
  • All clients are TypeScript (pure tRPC is better)
  • Internal-only APIs
  • No documentation requirements

在以下场景使用tRPC + OpenAPI:
  • 内部应用使用tRPC,但需要为第三方提供REST接口
  • 需要Swagger/OpenAPI文档
  • 移动应用(非React Native)需要REST端点
  • 多语言微服务需要互操作
  • 公开API要求遵循REST标准
在以下场景跳过OpenAPI层:
  • 所有客户端均为TypeScript(纯tRPC更合适)
  • 仅内部使用的API
  • 无文档需求

Quick Start

快速开始

Installation

安装

bash
undefined
bash
undefined

NOTE: trpc-openapi is ARCHIVED, use active fork

注意:trpc-openapi已归档,请使用活跃分支

npm install trpc-to-openapi swagger-ui-express
npm install -D @types/swagger-ui-express
undefined
npm install trpc-to-openapi swagger-ui-express
npm install -D @types/swagger-ui-express
undefined

Setup tRPC with OpenAPI Meta

配置带OpenAPI元数据的tRPC

typescript
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { OpenApiMeta } from 'trpc-to-openapi';

const t = initTRPC
  .context<Context>()
  .meta<OpenApiMeta>()  // ← Enable OpenAPI metadata
  .create();

export const router = t.router;
export const publicProcedure = t.procedure;

typescript
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { OpenApiMeta } from 'trpc-to-openapi';

const t = initTRPC
  .context<Context>()
  .meta<OpenApiMeta>()  // ← 启用OpenAPI元数据
  .create();

export const router = t.router;
export const publicProcedure = t.procedure;

Define Procedures with OpenAPI Metadata

定义带OpenAPI元数据的程序

typescript
// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
});

export const userRouter = router({
  // GET /api/users/{id}
  getById: publicProcedure
    .meta({
      openapi: {
        method: 'GET',
        path: '/users/{id}',
        tags: ['Users'],
        summary: 'Get user by ID',
        description: 'Retrieves a single user by their unique identifier',
      },
    })
    .input(z.object({ id: z.string() }))
    .output(UserSchema)
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
    }),

  // GET /api/users?limit=10&cursor=xxx
  list: publicProcedure
    .meta({
      openapi: {
        method: 'GET',
        path: '/users',
        tags: ['Users'],
        summary: 'List users',
      },
    })
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().optional(),
    }))
    .output(z.object({
      items: z.array(UserSchema),
      nextCursor: z.string().optional(),
    }))
    .query(async ({ input, ctx }) => {
      // pagination logic
    }),

  // POST /api/users (protected)
  create: protectedProcedure
    .meta({
      openapi: {
        method: 'POST',
        path: '/users',
        tags: ['Users'],
        summary: 'Create user',
        protect: true,  // ← Marks as requiring auth in docs
      },
    })
    .input(z.object({
      email: z.string().email(),
      name: z.string().min(2),
    }))
    .output(UserSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.db.user.create({ data: input });
    }),

  // PUT /api/users/{id}
  update: protectedProcedure
    .meta({
      openapi: {
        method: 'PUT',
        path: '/users/{id}',
        tags: ['Users'],
        protect: true,
      },
    })
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      email: z.string().email().optional(),
    }))
    .output(UserSchema)
    .mutation(async ({ input, ctx }) => {
      const { id, ...data } = input;
      return ctx.db.user.update({ where: { id }, data });
    }),

  // DELETE /api/users/{id}
  delete: protectedProcedure
    .meta({
      openapi: {
        method: 'DELETE',
        path: '/users/{id}',
        tags: ['Users'],
        protect: true,
      },
    })
    .input(z.object({ id: z.string() }))
    .output(z.object({ success: z.boolean() }))
    .mutation(async ({ input, ctx }) => {
      await ctx.db.user.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

typescript
// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
});

export const userRouter = router({
  // GET /api/users/{id}
  getById: publicProcedure
    .meta({
      openapi: {
        method: 'GET',
        path: '/users/{id}',
        tags: ['Users'],
        summary: '通过ID获取用户',
        description: '通过唯一标识符检索单个用户',
      },
    })
    .input(z.object({ id: z.string() }))
    .output(UserSchema)
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
    }),

  // GET /api/users?limit=10&cursor=xxx
  list: publicProcedure
    .meta({
      openapi: {
        method: 'GET',
        path: '/users',
        tags: ['Users'],
        summary: '列出用户',
      },
    })
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().optional(),
    }))
    .output(z.object({
      items: z.array(UserSchema),
      nextCursor: z.string().optional(),
    }))
    .query(async ({ input, ctx }) => {
      // 分页逻辑
    }),

  // POST /api/users(受保护)
  create: protectedProcedure
    .meta({
      openapi: {
        method: 'POST',
        path: '/users',
        tags: ['Users'],
        summary: '创建用户',
        protect: true,  // ← 在文档中标记为需要认证
      },
    })
    .input(z.object({
      email: z.string().email(),
      name: z.string().min(2),
    }))
    .output(UserSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.db.user.create({ data: input });
    }),

  // PUT /api/users/{id}
  update: protectedProcedure
    .meta({
      openapi: {
        method: 'PUT',
        path: '/users/{id}',
        tags: ['Users'],
        protect: true,
      },
    })
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      email: z.string().email().optional(),
    }))
    .output(UserSchema)
    .mutation(async ({ input, ctx }) => {
      const { id, ...data } = input;
      return ctx.db.user.update({ where: { id }, data });
    }),

  // DELETE /api/users/{id}
  delete: protectedProcedure
    .meta({
      openapi: {
        method: 'DELETE',
        path: '/users/{id}',
        tags: ['Users'],
        protect: true,
      },
    })
    .input(z.object({ id: z.string() }))
    .output(z.object({ success: z.boolean() }))
    .mutation(async ({ input, ctx }) => {
      await ctx.db.user.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

Generate OpenAPI Document

生成OpenAPI文档

typescript
// src/server/openapi.ts
import { generateOpenApiDocument } from 'trpc-to-openapi';
import { appRouter } from './routers/_app';

export const openApiDocument = generateOpenApiDocument(appRouter, {
  title: 'My API',
  version: '1.0.0',
  baseUrl: process.env.API_URL || 'http://localhost:3000/api',
  description: 'REST API documentation',
  securitySchemes: {
    bearerAuth: {
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'JWT',
    },
  },
});

typescript
// src/server/openapi.ts
import { generateOpenApiDocument } from 'trpc-to-openapi';
import { appRouter } from './routers/_app';

export const openApiDocument = generateOpenApiDocument(appRouter, {
  title: 'My API',
  version: '1.0.0',
  baseUrl: process.env.API_URL || 'http://localhost:3000/api',
  description: 'REST API文档',
  securitySchemes: {
    bearerAuth: {
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'JWT',
    },
  },
});

Serve REST Endpoints + Swagger UI

提供REST端点 + Swagger UI

typescript
// src/server/index.ts
import express from 'express';
import cors from 'cors';
import swaggerUi from 'swagger-ui-express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { createOpenApiExpressMiddleware } from 'trpc-to-openapi';
import { appRouter } from './routers/_app';
import { createContext } from './context';
import { openApiDocument } from './openapi';

const app = express();
app.use(cors());
app.use(express.json());

// tRPC endpoint (for TypeScript clients)
app.use('/trpc', createExpressMiddleware({
  router: appRouter,
  createContext,
}));

// REST/OpenAPI endpoints (for external clients)
app.use('/api', createOpenApiExpressMiddleware({
  router: appRouter,
  createContext,
}));

// Swagger UI documentation
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));

// OpenAPI JSON spec
app.get('/openapi.json', (req, res) => {
  res.json(openApiDocument);
});

app.listen(3000, () => {
  console.log('Server: http://localhost:3000');
  console.log('tRPC: http://localhost:3000/trpc');
  console.log('REST: http://localhost:3000/api');
  console.log('Docs: http://localhost:3000/docs');
});

typescript
// src/server/index.ts
import express from 'express';
import cors from 'cors';
import swaggerUi from 'swagger-ui-express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { createOpenApiExpressMiddleware } from 'trpc-to-openapi';
import { appRouter } from './routers/_app';
import { createContext } from './context';
import { openApiDocument } from './openapi';

const app = express();
app.use(cors());
app.use(express.json());

// tRPC端点(面向TypeScript客户端)
app.use('/trpc', createExpressMiddleware({
  router: appRouter,
  createContext,
}));

// REST/OpenAPI端点(面向外部客户端)
app.use('/api', createOpenApiExpressMiddleware({
  router: appRouter,
  createContext,
}));

// Swagger UI文档
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));

// OpenAPI JSON规范
app.get('/openapi.json', (req, res) => {
  res.json(openApiDocument);
});

app.listen(3000, () => {
  console.log('服务器:http://localhost:3000');
  console.log('tRPC:http://localhost:3000/trpc');
  console.log('REST:http://localhost:3000/api');
  console.log('文档:http://localhost:3000/docs');
});

URL Parameter Mapping

URL参数映射

typescript
// Path parameters use {param} syntax
.meta({
  openapi: {
    method: 'GET',
    path: '/users/{id}/posts/{postId}',
  },
})
.input(z.object({
  id: z.string(),       // ← Maps to {id}
  postId: z.string(),   // ← Maps to {postId}
}))

// Query parameters are auto-mapped for GET
.meta({
  openapi: {
    method: 'GET',
    path: '/users',
  },
})
.input(z.object({
  limit: z.number(),   // ← ?limit=10
  search: z.string(),  // ← &search=foo
}))

typescript
// 路径参数使用{param}语法
.meta({
  openapi: {
    method: 'GET',
    path: '/users/{id}/posts/{postId}',
  },
})
.input(z.object({
  id: z.string(),       // ← 映射到{id}
  postId: z.string(),   // ← 映射到{postId}
}))

// GET请求的查询参数会自动映射
.meta({
  openapi: {
    method: 'GET',
    path: '/users',
  },
})
.input(z.object({
  limit: z.number(),   // ← ?limit=10
  search: z.string(),  // ← &search=foo
}))

When to Expose OpenAPI

何时暴露OpenAPI

ScenarioRecommendation
Internal TypeScript clientsPure tRPC
Third-party integrationstRPC + OpenAPI
Public API documentationtRPC + OpenAPI
Mobile apps (non-React Native)tRPC + OpenAPI
Microservices (mixed languages)OpenAPI

场景推荐方案
内部TypeScript客户端纯tRPC
第三方集成tRPC + OpenAPI
公开API文档tRPC + OpenAPI
移动应用(非React Native)tRPC + OpenAPI
多语言微服务OpenAPI

Rules

规则

Do ✅

建议✅

  • Add
    .output()
    schema for OpenAPI response types
  • Use descriptive
    summary
    and
    description
  • Group related endpoints with
    tags
  • Mark protected routes with
    protect: true
  • Use path parameters for resource identifiers
  • 为OpenAPI响应类型添加
    .output()
    schema
  • 使用描述性的
    summary
    description
  • 使用
    tags
    对相关端点进行分组
  • protect: true
    标记受保护的路由
  • 对资源标识符使用路径参数

Avoid ❌

避免❌

  • Exposing all procedures (only add meta to public ones)
  • Missing output schemas (breaks OpenAPI generation)
  • Inconsistent path naming conventions
  • Skipping authentication markers

  • 暴露所有程序(仅为公开程序添加元数据)
  • 缺失输出schema(会导致OpenAPI生成失败)
  • 路径命名约定不一致
  • 跳过认证标记

OpenAPI Metadata Reference

OpenAPI元数据参考

typescript
.meta({
  openapi: {
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    path: '/resource/{id}',
    tags: ['Category'],
    summary: 'Short description',
    description: 'Detailed description',
    protect: boolean,           // Requires auth
    deprecated: boolean,        // Mark as deprecated
    requestHeaders: z.object(), // Custom headers
    responseHeaders: z.object(),
    contentTypes: ['application/json'],
  },
})

typescript
.meta({
  openapi: {
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    path: '/resource/{id}',
    tags: ['Category'],
    summary: '简短描述',
    description: '详细描述',
    protect: boolean,           // 需要认证
    deprecated: boolean,        // 标记为已弃用
    requestHeaders: z.object(), // 自定义请求头
    responseHeaders: z.object(),// 自定义响应头
    contentTypes: ['application/json'],
  },
})

Troubleshooting

故障排除

yaml
"OpenAPI generation fails":
  → Ensure all procedures with meta have .output()
  → Check Zod schemas are serializable
  → Verify path parameters match input schema

"REST endpoint returns 404":
  → Check path matches exactly (case-sensitive)
  → Verify HTTP method matches
  → Ensure createOpenApiExpressMiddleware is mounted

"Auth not working on REST":
  → Check Authorization header format
  → Verify createContext extracts token
  → Match auth middleware with tRPC setup

"Swagger UI empty":
  → Check openApiDocument is generated
  → Verify /openapi.json returns valid spec
  → Check console for generation errors

yaml
"OpenAPI生成失败":
  → 确保所有带元数据的程序都有.output()
  → 检查Zod schema是否可序列化
  → 验证路径参数与输入schema匹配

"REST端点返回404":
  → 检查路径是否完全匹配(区分大小写)
  → 验证HTTP方法是否匹配
  → 确保已挂载createOpenApiExpressMiddleware

"REST接口认证不生效":
  → 检查Authorization头格式
  → 验证createContext是否正确提取token
  → 确保认证中间件与tRPC配置一致

"Swagger UI为空":
  → 检查是否已生成openApiDocument
  → 验证/openapi.json返回有效的规范
  → 检查控制台是否有生成错误

File Structure

文件结构

src/server/
├── trpc.ts           # tRPC with OpenApiMeta
├── openapi.ts        # OpenAPI document generation
├── context.ts        # Shared context
├── index.ts          # Express server
└── routers/
    ├── _app.ts       # Root router
    └── user.ts       # Procedures with openapi meta
src/server/
├── trpc.ts           # 带OpenApiMeta的tRPC配置
├── openapi.ts        # OpenAPI文档生成
├── context.ts        # 共享上下文
├── index.ts          # Express服务器
└── routers/
    ├── _app.ts       # 根路由器
    └── user.ts       # 带openapi元数据的程序

References

参考链接