├── .eslintrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── assets ├── trpc-openapi-graph.png ├── trpc-openapi-readme.png └── trpc-openapi.svg ├── examples ├── with-express │ ├── README.md │ ├── package.json │ ├── src │ │ ├── database.ts │ │ ├── index.ts │ │ ├── openapi.ts │ │ └── router.ts │ └── tsconfig.json ├── with-fastify │ ├── README.md │ ├── package.json │ ├── src │ │ ├── database.ts │ │ ├── index.ts │ │ ├── openapi.ts │ │ └── router.ts │ └── tsconfig.json ├── with-interop │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── with-nextjs-appdir │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── app │ │ │ ├── api │ │ │ │ ├── [...trpc] │ │ │ │ │ └── route.ts │ │ │ │ ├── openapi.json │ │ │ │ │ └── route.ts │ │ │ │ └── trpc │ │ │ │ │ └── [trpc] │ │ │ │ │ └── route.ts │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── server │ │ │ ├── database.ts │ │ │ ├── openapi.ts │ │ │ └── router.ts │ └── tsconfig.json ├── with-nextjs │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── api │ │ │ │ ├── [...trpc].ts │ │ │ │ ├── openapi.json.ts │ │ │ │ └── trpc │ │ │ │ │ └── [...trpc].ts │ │ │ └── index.tsx │ │ └── server │ │ │ ├── database.ts │ │ │ ├── openapi.ts │ │ │ └── router.ts │ └── tsconfig.json ├── with-nuxtjs │ ├── .gitignore │ ├── .npmrc │ ├── README.md │ ├── app.vue │ ├── nuxt.config.ts │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── server │ │ ├── api │ │ │ ├── [...trpc].ts │ │ │ ├── openapi.json.ts │ │ │ └── trpc │ │ │ │ └── [trpc].ts │ │ ├── database.ts │ │ ├── openapi.ts │ │ └── router.ts │ └── tsconfig.json └── with-serverless │ ├── .gitignore │ ├── README.md │ ├── handler.ts │ ├── package.json │ ├── serverless.yml │ ├── src │ ├── database.ts │ ├── openapi.ts │ └── router.ts │ └── tsconfig.json ├── jest.config.ts ├── package-lock.json ├── package.json ├── prettier.config.js ├── rename.js ├── src ├── adapters │ ├── aws-lambda.ts │ ├── express.ts │ ├── fastify.ts │ ├── fetch.ts │ ├── index.ts │ ├── next.ts │ ├── node-http │ │ ├── core.ts │ │ ├── errors.ts │ │ ├── input.ts │ │ └── procedures.ts │ ├── nuxt.ts │ └── standalone.ts ├── generator │ ├── index.ts │ ├── paths.ts │ └── schema.ts ├── index.ts ├── types.ts └── utils │ ├── method.ts │ ├── path.ts │ ├── procedure.ts │ └── zod.ts ├── test ├── adapters │ ├── aws-lambda.test.ts │ ├── aws-lambda.utils.ts │ ├── express.test.ts │ ├── fastify.test.ts │ ├── fetch.test.ts │ ├── next.test.ts │ ├── nuxt.test.ts │ └── standalone.test.ts └── generator.test.ts ├── tsconfig.build.cjs.json ├── tsconfig.build.esm.json ├── tsconfig.build.json ├── tsconfig.eslint.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('eslint').Linter.Config} */ 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | 'plugin:promise/recommended', 13 | 'plugin:prettier/recommended', 14 | ], 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | ecmaFeatures: { jsx: false }, 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: './tsconfig.eslint.json', 21 | tsconfigRootDir: __dirname, 22 | }, 23 | env: { 24 | node: true, 25 | }, 26 | rules: { 27 | '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], 28 | '@typescript-eslint/no-unsafe-argument': 'warn', 29 | '@typescript-eslint/no-unsafe-assignment': 'warn', 30 | '@typescript-eslint/no-unsafe-call': 'warn', 31 | '@typescript-eslint/no-unsafe-member-access': 'warn', 32 | '@typescript-eslint/no-unsafe-return': 'warn', 33 | }, 34 | overrides: [ 35 | { 36 | files: ['*.js', '*.jsx'], 37 | rules: {}, 38 | }, 39 | ], 40 | ignorePatterns: ['rename.js'], 41 | }; 42 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lilyrose2798 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | reviewers: 8 | - 'lilyrose2798' 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout repo 8 | uses: actions/checkout@v2 9 | 10 | - name: Setup node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 16 14 | 15 | - name: Install dependencies 16 | run: npm ci --force 17 | 18 | - name: Run tests 19 | run: npm test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/**/* 3 | !package.json 4 | !package-lock.json 5 | !LICENSE -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | sign-git-tag=false 2 | message="@lilyrose2798/trpc-openapi v%s" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "yzhang.markdown-all-in-one", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch via NPM", 9 | "request": "launch", 10 | "runtimeArgs": ["run-script", "test"], 11 | "runtimeExecutable": "npm", 12 | "skipFiles": ["/**"], 13 | "type": "node" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | }, 8 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], 9 | "[javascript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[jsonc]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 LilyRose2798 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /assets/trpc-openapi-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyRose2798/trpc-openapi/0f33bee81bc8c6df18752d4bd68ca4a5eab1be9a/assets/trpc-openapi-graph.png -------------------------------------------------------------------------------- /assets/trpc-openapi-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyRose2798/trpc-openapi/0f33bee81bc8c6df18752d4bd68ca4a5eab1be9a/assets/trpc-openapi-readme.png -------------------------------------------------------------------------------- /assets/trpc-openapi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/with-express/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-express) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-express 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/with-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-express", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "ts-node-dev --respawn --transpile-only --exit-child ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "@trpc/server": "^10.27.1", 10 | "cors": "^2.8.5", 11 | "express": "^4.18.2", 12 | "jsonwebtoken": "^9.0.0", 13 | "swagger-ui-express": "^4.6.3", 14 | "uuid": "^9.0.0", 15 | "zod": "^3.21.4" 16 | }, 17 | "devDependencies": { 18 | "@types/cors": "^2.8.13", 19 | "@types/express": "^4.17.17", 20 | "@types/jsonwebtoken": "^9.0.2", 21 | "@types/node": "^20.2.3", 22 | "@types/swagger-ui-express": "^4.1.3", 23 | "@types/uuid": "^9.0.1", 24 | "ts-node": "^10.9.1", 25 | "ts-node-dev": "^2.0.0", 26 | "typescript": "^5.0.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/with-express/src/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: number; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: 1234, 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: 9876, 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: 5678, 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-express/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-misused-promises */ 2 | import { createOpenApiExpressMiddleware } from '@lilyrose2798/trpc-openapi'; 3 | import { createExpressMiddleware } from '@trpc/server/adapters/express'; 4 | import cors from 'cors'; 5 | import express from 'express'; 6 | import swaggerUi from 'swagger-ui-express'; 7 | 8 | import { openApiDocument } from './openapi'; 9 | import { appRouter, createContext } from './router'; 10 | 11 | const app = express(); 12 | 13 | // Setup CORS 14 | app.use(cors()); 15 | 16 | // Handle incoming tRPC requests 17 | app.use('/api/trpc', createExpressMiddleware({ router: appRouter, createContext })); 18 | // Handle incoming OpenAPI requests 19 | app.use('/api', createOpenApiExpressMiddleware({ router: appRouter, createContext })); 20 | 21 | // Serve Swagger UI with our OpenAPI schema 22 | app.use('/', swaggerUi.serve); 23 | app.get('/', swaggerUi.setup(openApiDocument)); 24 | 25 | app.listen(3000, () => { 26 | console.log('Server started on http://localhost:3000'); 27 | }); 28 | -------------------------------------------------------------------------------- /examples/with-express/src/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Express', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-express/src/router.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMeta } from '@lilyrose2798/trpc-openapi'; 2 | import { TRPCError, initTRPC } from '@trpc/server'; 3 | import { CreateExpressContextOptions } from '@trpc/server/adapters/express'; 4 | import jwt from 'jsonwebtoken'; 5 | import { v4 as uuid } from 'uuid'; 6 | import { z } from 'zod'; 7 | 8 | import { Post, User, database } from './database'; 9 | 10 | const jwtSecret = uuid(); 11 | 12 | export type Context = { 13 | user: User | null; 14 | requestId: string; 15 | }; 16 | 17 | const t = initTRPC 18 | .context() 19 | .meta() 20 | .create({ 21 | errorFormatter: ({ error, shape }) => { 22 | if (error.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production') { 23 | return { ...shape, message: 'Internal server error' }; 24 | } 25 | return shape; 26 | }, 27 | }); 28 | 29 | export const createContext = async ({ 30 | req, 31 | res, 32 | }: // eslint-disable-next-line @typescript-eslint/require-await 33 | CreateExpressContextOptions): Promise => { 34 | const requestId = uuid(); 35 | res.setHeader('x-request-id', requestId); 36 | 37 | let user: User | null = null; 38 | 39 | try { 40 | if (req.headers.authorization) { 41 | const token = req.headers.authorization.split(' ')[1]; 42 | const userId = jwt.verify(token, jwtSecret) as string; 43 | if (userId) { 44 | user = database.users.find((_user) => _user.id === userId) ?? null; 45 | } 46 | } 47 | } catch (cause) { 48 | console.error(cause); 49 | } 50 | 51 | return { user, requestId }; 52 | }; 53 | 54 | const publicProcedure = t.procedure; 55 | const protectedProcedure = t.procedure.use(({ ctx, next }) => { 56 | if (!ctx.user) { 57 | throw new TRPCError({ 58 | message: 'User not found', 59 | code: 'UNAUTHORIZED', 60 | }); 61 | } 62 | return next({ ctx: { ...ctx, user: ctx.user } }); 63 | }); 64 | 65 | const authRouter = t.router({ 66 | register: publicProcedure 67 | .meta({ 68 | openapi: { 69 | method: 'POST', 70 | path: '/auth/register', 71 | tags: ['auth'], 72 | summary: 'Register as a new user', 73 | }, 74 | }) 75 | .input( 76 | z.object({ 77 | email: z.string().email(), 78 | passcode: z.preprocess( 79 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 80 | z.number().min(1000).max(9999), 81 | ), 82 | name: z.string().min(3), 83 | }), 84 | ) 85 | .output( 86 | z.object({ 87 | user: z.object({ 88 | id: z.string().uuid(), 89 | email: z.string().email(), 90 | name: z.string().min(3), 91 | }), 92 | }), 93 | ) 94 | .mutation(({ input }) => { 95 | let user = database.users.find((_user) => _user.email === input.email); 96 | 97 | if (user) { 98 | throw new TRPCError({ 99 | message: 'User with email already exists', 100 | code: 'UNAUTHORIZED', 101 | }); 102 | } 103 | 104 | user = { 105 | id: uuid(), 106 | email: input.email, 107 | passcode: input.passcode, 108 | name: input.name, 109 | }; 110 | 111 | database.users.push(user); 112 | 113 | return { user: { id: user.id, email: user.email, name: user.name } }; 114 | }), 115 | login: publicProcedure 116 | .meta({ 117 | openapi: { 118 | method: 'POST', 119 | path: '/auth/login', 120 | tags: ['auth'], 121 | summary: 'Login as an existing user', 122 | }, 123 | }) 124 | .input( 125 | z.object({ 126 | email: z.string().email(), 127 | passcode: z.preprocess( 128 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 129 | z.number().min(1000).max(9999), 130 | ), 131 | }), 132 | ) 133 | .output( 134 | z.object({ 135 | token: z.string(), 136 | }), 137 | ) 138 | .mutation(({ input }) => { 139 | const user = database.users.find((_user) => _user.email === input.email); 140 | 141 | if (!user) { 142 | throw new TRPCError({ 143 | message: 'User with email not found', 144 | code: 'UNAUTHORIZED', 145 | }); 146 | } 147 | if (user.passcode !== input.passcode) { 148 | throw new TRPCError({ 149 | message: 'Passcode was incorrect', 150 | code: 'UNAUTHORIZED', 151 | }); 152 | } 153 | 154 | return { 155 | token: jwt.sign(user.id, jwtSecret), 156 | }; 157 | }), 158 | }); 159 | 160 | const usersRouter = t.router({ 161 | getUsers: publicProcedure 162 | .meta({ 163 | openapi: { 164 | method: 'GET', 165 | path: '/users', 166 | tags: ['users'], 167 | summary: 'Read all users', 168 | }, 169 | }) 170 | .input(z.void()) 171 | .output( 172 | z.object({ 173 | users: z.array( 174 | z.object({ 175 | id: z.string().uuid(), 176 | email: z.string().email(), 177 | name: z.string(), 178 | }), 179 | ), 180 | }), 181 | ) 182 | .query(() => { 183 | const users = database.users.map((user) => ({ 184 | id: user.id, 185 | email: user.email, 186 | name: user.name, 187 | })); 188 | 189 | return { users }; 190 | }), 191 | getUserById: publicProcedure 192 | .meta({ 193 | openapi: { 194 | method: 'GET', 195 | path: '/users/{id}', 196 | tags: ['users'], 197 | summary: 'Read a user by id', 198 | }, 199 | }) 200 | .input( 201 | z.object({ 202 | id: z.string().uuid(), 203 | }), 204 | ) 205 | .output( 206 | z.object({ 207 | user: z.object({ 208 | id: z.string().uuid(), 209 | email: z.string().email(), 210 | name: z.string(), 211 | }), 212 | }), 213 | ) 214 | .query(({ input }) => { 215 | const user = database.users.find((_user) => _user.id === input.id); 216 | 217 | if (!user) { 218 | throw new TRPCError({ 219 | message: 'User not found', 220 | code: 'NOT_FOUND', 221 | }); 222 | } 223 | 224 | return { user }; 225 | }), 226 | }); 227 | 228 | const postsRouter = t.router({ 229 | getPosts: publicProcedure 230 | .meta({ 231 | openapi: { 232 | method: 'GET', 233 | path: '/posts', 234 | tags: ['posts'], 235 | summary: 'Read all posts', 236 | }, 237 | }) 238 | .input( 239 | z.object({ 240 | userId: z.string().uuid().optional(), 241 | }), 242 | ) 243 | .output( 244 | z.object({ 245 | posts: z.array( 246 | z.object({ 247 | id: z.string().uuid(), 248 | content: z.string(), 249 | userId: z.string().uuid(), 250 | }), 251 | ), 252 | }), 253 | ) 254 | .query(({ input }) => { 255 | let posts: Post[] = database.posts; 256 | 257 | if (input.userId) { 258 | posts = posts.filter((post) => { 259 | return post.userId === input.userId; 260 | }); 261 | } 262 | 263 | return { posts }; 264 | }), 265 | getPostById: publicProcedure 266 | .meta({ 267 | openapi: { 268 | method: 'GET', 269 | path: '/posts/{id}', 270 | tags: ['posts'], 271 | summary: 'Read a post by id', 272 | }, 273 | }) 274 | .input( 275 | z.object({ 276 | id: z.string().uuid(), 277 | }), 278 | ) 279 | .output( 280 | z.object({ 281 | post: z.object({ 282 | id: z.string().uuid(), 283 | content: z.string(), 284 | userId: z.string().uuid(), 285 | }), 286 | }), 287 | ) 288 | .query(({ input }) => { 289 | const post = database.posts.find((_post) => _post.id === input.id); 290 | 291 | if (!post) { 292 | throw new TRPCError({ 293 | message: 'Post not found', 294 | code: 'NOT_FOUND', 295 | }); 296 | } 297 | 298 | return { post }; 299 | }), 300 | createPost: protectedProcedure 301 | .meta({ 302 | openapi: { 303 | method: 'POST', 304 | path: '/posts', 305 | tags: ['posts'], 306 | protect: true, 307 | summary: 'Create a new post', 308 | }, 309 | }) 310 | .input( 311 | z.object({ 312 | content: z.string().min(1).max(140), 313 | }), 314 | ) 315 | .output( 316 | z.object({ 317 | post: z.object({ 318 | id: z.string().uuid(), 319 | content: z.string(), 320 | userId: z.string().uuid(), 321 | }), 322 | }), 323 | ) 324 | .mutation(({ input, ctx }) => { 325 | const post: Post = { 326 | id: uuid(), 327 | content: input.content, 328 | userId: ctx.user.id, 329 | }; 330 | 331 | database.posts.push(post); 332 | 333 | return { post }; 334 | }), 335 | updatePostById: protectedProcedure 336 | .meta({ 337 | openapi: { 338 | method: 'PUT', 339 | path: '/posts/{id}', 340 | tags: ['posts'], 341 | protect: true, 342 | summary: 'Update an existing post', 343 | }, 344 | }) 345 | .input( 346 | z.object({ 347 | id: z.string().uuid(), 348 | content: z.string().min(1), 349 | }), 350 | ) 351 | .output( 352 | z.object({ 353 | post: z.object({ 354 | id: z.string().uuid(), 355 | content: z.string(), 356 | userId: z.string().uuid(), 357 | }), 358 | }), 359 | ) 360 | .mutation(({ input, ctx }) => { 361 | const post = database.posts.find((_post) => _post.id === input.id); 362 | 363 | if (!post) { 364 | throw new TRPCError({ 365 | message: 'Post not found', 366 | code: 'NOT_FOUND', 367 | }); 368 | } 369 | if (post.userId !== ctx.user.id) { 370 | throw new TRPCError({ 371 | message: 'Cannot edit post owned by other user', 372 | code: 'FORBIDDEN', 373 | }); 374 | } 375 | 376 | post.content = input.content; 377 | 378 | return { post }; 379 | }), 380 | deletePostById: protectedProcedure 381 | .meta({ 382 | openapi: { 383 | method: 'DELETE', 384 | path: '/posts/{id}', 385 | tags: ['posts'], 386 | protect: true, 387 | summary: 'Delete a post', 388 | }, 389 | }) 390 | .input( 391 | z.object({ 392 | id: z.string().uuid(), 393 | }), 394 | ) 395 | .output(z.null()) 396 | .mutation(({ input, ctx }) => { 397 | const post = database.posts.find((_post) => _post.id === input.id); 398 | 399 | if (!post) { 400 | throw new TRPCError({ 401 | message: 'Post not found', 402 | code: 'NOT_FOUND', 403 | }); 404 | } 405 | if (post.userId !== ctx.user.id) { 406 | throw new TRPCError({ 407 | message: 'Cannot delete post owned by other user', 408 | code: 'FORBIDDEN', 409 | }); 410 | } 411 | 412 | database.posts = database.posts.filter((_post) => _post !== post); 413 | 414 | return null; 415 | }), 416 | }); 417 | 418 | export const appRouter = t.router({ 419 | auth: authRouter, 420 | users: usersRouter, 421 | posts: postsRouter, 422 | }); 423 | 424 | export type AppRouter = typeof appRouter; 425 | -------------------------------------------------------------------------------- /examples/with-express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": ["node_modules"] 102 | } 103 | -------------------------------------------------------------------------------- /examples/with-fastify/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-fastify) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-fastify 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/with-fastify/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-fastify", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "ts-node-dev --respawn --transpile-only --exit-child ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "@fastify/cors": "^8.2.1", 10 | "@fastify/swagger": "^8.5.1", 11 | "@trpc/server": "^10.27.1", 12 | "fastify": "^4.17.0", 13 | "jsonwebtoken": "^9.0.0", 14 | "uuid": "^9.0.0", 15 | "zod": "^3.21.4" 16 | }, 17 | "devDependencies": { 18 | "@types/cors": "^2.8.13", 19 | "@types/express": "^4.17.17", 20 | "@types/jsonwebtoken": "^9.0.2", 21 | "@types/node": "^20.2.3", 22 | "@types/uuid": "^9.0.1", 23 | "ts-node": "^10.9.1", 24 | "ts-node-dev": "^2.0.0", 25 | "typescript": "^5.0.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/with-fastify/src/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: string; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: '1234', 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: '9876', 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: '1234', 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-fastify/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | eslint-disable 3 | @typescript-eslint/no-misused-promises, 4 | @typescript-eslint/no-unsafe-argument, 5 | @typescript-eslint/no-explicit-any, 6 | promise/always-return 7 | */ 8 | import cors from '@fastify/cors'; 9 | import fastifySwagger from '@fastify/swagger'; 10 | import { fastifyTRPCOpenApiPlugin } from '@lilyrose2798/trpc-openapi'; 11 | import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; 12 | import Fastify from 'fastify'; 13 | 14 | import { openApiDocument } from './openapi'; 15 | import { appRouter, createContext } from './router'; 16 | 17 | const app = Fastify(); 18 | 19 | async function main() { 20 | // Setup CORS 21 | await app.register(cors); 22 | 23 | // Handle incoming tRPC requests 24 | await app.register(fastifyTRPCPlugin, { 25 | prefix: '/trpc', 26 | useWss: false, 27 | trpcOptions: { router: appRouter, createContext }, 28 | } as any); 29 | 30 | // Handle incoming OpenAPI requests 31 | await app.register(fastifyTRPCOpenApiPlugin, { 32 | basePath: '/api', 33 | router: appRouter, 34 | createContext, 35 | }); 36 | 37 | // Serve the OpenAPI document 38 | app.get('/openapi.json', () => openApiDocument); 39 | 40 | // Server Swagger UI 41 | await app.register(fastifySwagger, { 42 | routePrefix: '/docs', 43 | mode: 'static', 44 | specification: { document: openApiDocument }, 45 | uiConfig: { displayOperationId: true }, 46 | exposeRoute: true, 47 | }); 48 | 49 | await app 50 | .listen({ port: 3000 }) 51 | .then((address) => { 52 | app.swagger(); 53 | console.log(`Server started on ${address}\nSwagger UI: http://localhost:3000/docs`); 54 | }) 55 | .catch((e) => { 56 | throw e; 57 | }); 58 | } 59 | 60 | main().catch((err) => { 61 | console.error(err); 62 | process.exit(1); 63 | }); 64 | -------------------------------------------------------------------------------- /examples/with-fastify/src/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Fastify', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-fastify/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": ["node_modules"] 102 | } 103 | -------------------------------------------------------------------------------- /examples/with-interop/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-interop) 2 | 3 | ### Getting started 4 | 5 | This example shows the pattern to use a tRPC v10 `.interop()` router whilst also supporting `@lilyrose2798/trpc-openapi`. 6 | 7 | ```bash 8 | npm install @trpc/server@next 9 | npm install @lilyrose2798/trpc-openapi@0 --force 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/with-interop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-interop", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@trpc/server": "^10.27.1", 7 | "zod": "^3.21.4" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^20.2.3", 11 | "typescript": "^5.0.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/with-interop/src/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMeta } from '@lilyrose2798/trpc-openapi'; 2 | import * as trpc from '@trpc/server'; 3 | import { z } from 'zod'; 4 | 5 | const appRouter = trpc.router().query('echo', { 6 | meta: { openapi: { enabled: true, method: 'GET', path: '/echo' } }, 7 | input: z.object({ payload: z.string() }), 8 | output: z.object({ payload: z.string() }), 9 | resolve: ({ input }) => input, 10 | }); 11 | 12 | export const trpcV10AppRouter = appRouter.interop(); 13 | export const openApiV0AppRouter = appRouter; 14 | 15 | export type AppRouter = typeof trpcV10AppRouter; 16 | 17 | // Now add your `@trpc/server` && `@lilyrose2798/trpc-openapi` handlers... 18 | -------------------------------------------------------------------------------- /examples/with-interop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | /.swc/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-nextjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-nextjs 11 | ``` 12 | D -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-nextjs-appdir", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@trpc/server": "^10.27.1", 13 | "jsonwebtoken": "^9.0.0", 14 | "next": "^13.4.3", 15 | "nextjs-cors": "^2.1.2", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "swagger-ui-react": "^4.18.3", 19 | "uuid": "^9.0.0", 20 | "zod": "^3.21.4" 21 | }, 22 | "devDependencies": { 23 | "@types/jsonwebtoken": "^9.0.2", 24 | "@types/node": "^20.2.3", 25 | "@types/react": "^18.2.6", 26 | "@types/react-dom": "^18.2.4", 27 | "@types/swagger-ui-react": "^4.18.0", 28 | "@types/uuid": "^9.0.1", 29 | "eslint": "^8.41.0", 30 | "eslint-config-next": "^13.4.3", 31 | "typescript": "^5.0.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyRose2798/trpc-openapi/0f33bee81bc8c6df18752d4bd68ca4a5eab1be9a/examples/with-nextjs-appdir/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/api/[...trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiFetchHandler } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter, createContext } from '../../../server/router'; 4 | 5 | const handler = (req: Request) => { 6 | // Handle incoming OpenAPI requests 7 | return createOpenApiFetchHandler({ 8 | endpoint: '/api', 9 | router: appRouter, 10 | createContext, 11 | req, 12 | }); 13 | }; 14 | 15 | export { 16 | handler as GET, 17 | handler as POST, 18 | handler as PUT, 19 | handler as PATCH, 20 | handler as DELETE, 21 | handler as OPTIONS, 22 | handler as HEAD, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/api/openapi.json/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | import { openApiDocument } from '../../../server/openapi'; 4 | 5 | // Respond with our OpenAPI schema 6 | export const GET = () => { 7 | return NextResponse.json(openApiDocument); 8 | }; 9 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 2 | 3 | import { appRouter, createContext } from '../../../../server/router'; 4 | 5 | const handler = (req: Request) => { 6 | return fetchRequestHandler({ 7 | req, 8 | endpoint: '/api/trpc', 9 | router: appRouter, 10 | createContext, 11 | }); 12 | }; 13 | 14 | export { handler as GET, handler as POST }; 15 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: { children: React.ReactNode }) { 2 | return ( 3 | 4 | {children} 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import dynamic from 'next/dynamic'; 4 | import 'swagger-ui-react/swagger-ui.css'; 5 | 6 | const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false }); 7 | 8 | const Home = () => { 9 | // Serve Swagger UI with our OpenAPI schema 10 | return ; 11 | }; 12 | 13 | export default Home; 14 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/server/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: number; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: 1234, 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: 9876, 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: 5678, 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/server/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Next.js', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /examples/with-nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/with-nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | /.swc/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /examples/with-nextjs/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-nextjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-nextjs 11 | ``` 12 | D -------------------------------------------------------------------------------- /examples/with-nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/with-nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /examples/with-nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-nextjs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@trpc/server": "^10.27.1", 13 | "jsonwebtoken": "^9.0.0", 14 | "next": "^13.4.3", 15 | "nextjs-cors": "^2.1.2", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "swagger-ui-react": "^4.18.3", 19 | "uuid": "^9.0.0", 20 | "zod": "^3.21.4" 21 | }, 22 | "devDependencies": { 23 | "@types/jsonwebtoken": "^9.0.2", 24 | "@types/node": "^20.2.3", 25 | "@types/react": "^18.2.6", 26 | "@types/react-dom": "^18.2.4", 27 | "@types/swagger-ui-react": "^4.18.0", 28 | "@types/uuid": "^9.0.1", 29 | "eslint": "^8.41.0", 30 | "eslint-config-next": "^13.4.3", 31 | "typescript": "^5.0.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/with-nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyRose2798/trpc-openapi/0f33bee81bc8c6df18752d4bd68ca4a5eab1be9a/examples/with-nextjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-nextjs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | 3 | function MyApp({ Component, pageProps }: AppProps) { 4 | return ; 5 | } 6 | 7 | export default MyApp; 8 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/pages/api/[...trpc].ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiNextHandler } from '@lilyrose2798/trpc-openapi'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import cors from 'nextjs-cors'; 4 | 5 | import { appRouter, createContext } from '../../server/router'; 6 | 7 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 8 | // Setup CORS 9 | await cors(req, res); 10 | 11 | // Handle incoming OpenAPI requests 12 | return createOpenApiNextHandler({ 13 | router: appRouter, 14 | createContext, 15 | })(req, res); 16 | }; 17 | 18 | export default handler; 19 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/pages/api/openapi.json.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import { openApiDocument } from '../../server/openapi'; 4 | 5 | // Respond with our OpenAPI schema 6 | const handler = (req: NextApiRequest, res: NextApiResponse) => { 7 | res.status(200).send(openApiDocument); 8 | }; 9 | 10 | export default handler; 11 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/pages/api/trpc/[...trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from '@trpc/server/adapters/next'; 2 | 3 | import { appRouter, createContext } from '../../../server/router'; 4 | 5 | // Handle incoming tRPC requests 6 | export default createNextApiHandler({ 7 | router: appRouter, 8 | createContext, 9 | }); 10 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import dynamic from 'next/dynamic'; 3 | import 'swagger-ui-react/swagger-ui.css'; 4 | 5 | const SwaggerUI = dynamic(() => import('swagger-ui-react'), { ssr: false }); 6 | 7 | const Home: NextPage = () => { 8 | // Serve Swagger UI with our OpenAPI schema 9 | return ; 10 | }; 11 | 12 | export default Home; 13 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/server/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: number; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: 1234, 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: 9876, 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: 5678, 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/server/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Next.js', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/server/router.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMeta } from '@lilyrose2798/trpc-openapi'; 2 | import { TRPCError, initTRPC } from '@trpc/server'; 3 | import { CreateNextContextOptions } from '@trpc/server/adapters/next'; 4 | import jwt from 'jsonwebtoken'; 5 | import { v4 as uuid } from 'uuid'; 6 | import { z } from 'zod'; 7 | 8 | import { Post, User, database } from './database'; 9 | 10 | const jwtSecret = uuid(); 11 | 12 | export type Context = { 13 | user: User | null; 14 | requestId: string; 15 | }; 16 | 17 | const t = initTRPC 18 | .context() 19 | .meta() 20 | .create({ 21 | errorFormatter: ({ error, shape }) => { 22 | if (error.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production') { 23 | return { ...shape, message: 'Internal server error' }; 24 | } 25 | return shape; 26 | }, 27 | }); 28 | 29 | // eslint-disable-next-line @typescript-eslint/require-await 30 | export const createContext = async ({ req, res }: CreateNextContextOptions): Promise => { 31 | const requestId = uuid(); 32 | res.setHeader('x-request-id', requestId); 33 | 34 | let user: User | null = null; 35 | 36 | try { 37 | if (req.headers.authorization) { 38 | const token = req.headers.authorization.split(' ')[1]; 39 | const userId = jwt.verify(token, jwtSecret) as string; 40 | if (userId) { 41 | user = database.users.find((_user) => _user.id === userId) ?? null; 42 | } 43 | } 44 | } catch (cause) { 45 | console.error(cause); 46 | } 47 | 48 | return { user, requestId }; 49 | }; 50 | 51 | const publicProcedure = t.procedure; 52 | const protectedProcedure = t.procedure.use(({ ctx, next }) => { 53 | if (!ctx.user) { 54 | throw new TRPCError({ 55 | message: 'User not found', 56 | code: 'UNAUTHORIZED', 57 | }); 58 | } 59 | return next({ ctx: { ...ctx, user: ctx.user } }); 60 | }); 61 | 62 | const authRouter = t.router({ 63 | register: publicProcedure 64 | .meta({ 65 | openapi: { 66 | method: 'POST', 67 | path: '/auth/register', 68 | tags: ['auth'], 69 | summary: 'Register as a new user', 70 | }, 71 | }) 72 | .input( 73 | z.object({ 74 | email: z.string().email(), 75 | passcode: z.preprocess( 76 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 77 | z.number().min(1000).max(9999), 78 | ), 79 | name: z.string().min(3), 80 | }), 81 | ) 82 | .output( 83 | z.object({ 84 | user: z.object({ 85 | id: z.string().uuid(), 86 | email: z.string().email(), 87 | name: z.string().min(3), 88 | }), 89 | }), 90 | ) 91 | .mutation(({ input }) => { 92 | let user = database.users.find((_user) => _user.email === input.email); 93 | 94 | if (user) { 95 | throw new TRPCError({ 96 | message: 'User with email already exists', 97 | code: 'UNAUTHORIZED', 98 | }); 99 | } 100 | 101 | user = { 102 | id: uuid(), 103 | email: input.email, 104 | passcode: input.passcode, 105 | name: input.name, 106 | }; 107 | 108 | database.users.push(user); 109 | 110 | return { user: { id: user.id, email: user.email, name: user.name } }; 111 | }), 112 | login: publicProcedure 113 | .meta({ 114 | openapi: { 115 | method: 'POST', 116 | path: '/auth/login', 117 | tags: ['auth'], 118 | summary: 'Login as an existing user', 119 | }, 120 | }) 121 | .input( 122 | z.object({ 123 | email: z.string().email(), 124 | passcode: z.preprocess( 125 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 126 | z.number().min(1000).max(9999), 127 | ), 128 | }), 129 | ) 130 | .output( 131 | z.object({ 132 | token: z.string(), 133 | }), 134 | ) 135 | .mutation(({ input }) => { 136 | const user = database.users.find((_user) => _user.email === input.email); 137 | 138 | if (!user) { 139 | throw new TRPCError({ 140 | message: 'User with email not found', 141 | code: 'UNAUTHORIZED', 142 | }); 143 | } 144 | if (user.passcode !== input.passcode) { 145 | throw new TRPCError({ 146 | message: 'Passcode was incorrect', 147 | code: 'UNAUTHORIZED', 148 | }); 149 | } 150 | 151 | return { 152 | token: jwt.sign(user.id, jwtSecret), 153 | }; 154 | }), 155 | }); 156 | 157 | const usersRouter = t.router({ 158 | getUsers: publicProcedure 159 | .meta({ 160 | openapi: { 161 | method: 'GET', 162 | path: '/users', 163 | tags: ['users'], 164 | summary: 'Read all users', 165 | }, 166 | }) 167 | .input(z.void()) 168 | .output( 169 | z.object({ 170 | users: z.array( 171 | z.object({ 172 | id: z.string().uuid(), 173 | email: z.string().email(), 174 | name: z.string(), 175 | }), 176 | ), 177 | }), 178 | ) 179 | .query(() => { 180 | const users = database.users.map((user) => ({ 181 | id: user.id, 182 | email: user.email, 183 | name: user.name, 184 | })); 185 | 186 | return { users }; 187 | }), 188 | getUserById: publicProcedure 189 | .meta({ 190 | openapi: { 191 | method: 'GET', 192 | path: '/users/{id}', 193 | tags: ['users'], 194 | summary: 'Read a user by id', 195 | }, 196 | }) 197 | .input( 198 | z.object({ 199 | id: z.string().uuid(), 200 | }), 201 | ) 202 | .output( 203 | z.object({ 204 | user: z.object({ 205 | id: z.string().uuid(), 206 | email: z.string().email(), 207 | name: z.string(), 208 | }), 209 | }), 210 | ) 211 | .query(({ input }) => { 212 | const user = database.users.find((_user) => _user.id === input.id); 213 | 214 | if (!user) { 215 | throw new TRPCError({ 216 | message: 'User not found', 217 | code: 'NOT_FOUND', 218 | }); 219 | } 220 | 221 | return { user }; 222 | }), 223 | }); 224 | 225 | const postsRouter = t.router({ 226 | getPosts: publicProcedure 227 | .meta({ 228 | openapi: { 229 | method: 'GET', 230 | path: '/posts', 231 | tags: ['posts'], 232 | summary: 'Read all posts', 233 | }, 234 | }) 235 | .input( 236 | z.object({ 237 | userId: z.string().uuid().optional(), 238 | }), 239 | ) 240 | .output( 241 | z.object({ 242 | posts: z.array( 243 | z.object({ 244 | id: z.string().uuid(), 245 | content: z.string(), 246 | userId: z.string().uuid(), 247 | }), 248 | ), 249 | }), 250 | ) 251 | .query(({ input }) => { 252 | let posts: Post[] = database.posts; 253 | 254 | if (input.userId) { 255 | posts = posts.filter((post) => { 256 | return post.userId === input.userId; 257 | }); 258 | } 259 | 260 | return { posts }; 261 | }), 262 | getPostById: publicProcedure 263 | .meta({ 264 | openapi: { 265 | method: 'GET', 266 | path: '/posts/{id}', 267 | tags: ['posts'], 268 | summary: 'Read a post by id', 269 | }, 270 | }) 271 | .input( 272 | z.object({ 273 | id: z.string().uuid(), 274 | }), 275 | ) 276 | .output( 277 | z.object({ 278 | post: z.object({ 279 | id: z.string().uuid(), 280 | content: z.string(), 281 | userId: z.string().uuid(), 282 | }), 283 | }), 284 | ) 285 | .query(({ input }) => { 286 | const post = database.posts.find((_post) => _post.id === input.id); 287 | 288 | if (!post) { 289 | throw new TRPCError({ 290 | message: 'Post not found', 291 | code: 'NOT_FOUND', 292 | }); 293 | } 294 | 295 | return { post }; 296 | }), 297 | createPost: protectedProcedure 298 | .meta({ 299 | openapi: { 300 | method: 'POST', 301 | path: '/posts', 302 | tags: ['posts'], 303 | protect: true, 304 | summary: 'Create a new post', 305 | }, 306 | }) 307 | .input( 308 | z.object({ 309 | content: z.string().min(1).max(140), 310 | }), 311 | ) 312 | .output( 313 | z.object({ 314 | post: z.object({ 315 | id: z.string().uuid(), 316 | content: z.string(), 317 | userId: z.string().uuid(), 318 | }), 319 | }), 320 | ) 321 | .mutation(({ input, ctx }) => { 322 | const post: Post = { 323 | id: uuid(), 324 | content: input.content, 325 | userId: ctx.user.id, 326 | }; 327 | 328 | database.posts.push(post); 329 | 330 | return { post }; 331 | }), 332 | updatePostById: protectedProcedure 333 | .meta({ 334 | openapi: { 335 | method: 'PUT', 336 | path: '/posts/{id}', 337 | tags: ['posts'], 338 | protect: true, 339 | summary: 'Update an existing post', 340 | }, 341 | }) 342 | .input( 343 | z.object({ 344 | id: z.string().uuid(), 345 | content: z.string().min(1), 346 | }), 347 | ) 348 | .output( 349 | z.object({ 350 | post: z.object({ 351 | id: z.string().uuid(), 352 | content: z.string(), 353 | userId: z.string().uuid(), 354 | }), 355 | }), 356 | ) 357 | .mutation(({ input, ctx }) => { 358 | const post = database.posts.find((_post) => _post.id === input.id); 359 | 360 | if (!post) { 361 | throw new TRPCError({ 362 | message: 'Post not found', 363 | code: 'NOT_FOUND', 364 | }); 365 | } 366 | if (post.userId !== ctx.user.id) { 367 | throw new TRPCError({ 368 | message: 'Cannot edit post owned by other user', 369 | code: 'FORBIDDEN', 370 | }); 371 | } 372 | 373 | post.content = input.content; 374 | 375 | return { post }; 376 | }), 377 | deletePostById: protectedProcedure 378 | .meta({ 379 | openapi: { 380 | method: 'DELETE', 381 | path: '/posts/{id}', 382 | tags: ['posts'], 383 | protect: true, 384 | summary: 'Delete a post', 385 | }, 386 | }) 387 | .input( 388 | z.object({ 389 | id: z.string().uuid(), 390 | }), 391 | ) 392 | .output(z.null()) 393 | .mutation(({ input, ctx }) => { 394 | const post = database.posts.find((_post) => _post.id === input.id); 395 | 396 | if (!post) { 397 | throw new TRPCError({ 398 | message: 'Post not found', 399 | code: 'NOT_FOUND', 400 | }); 401 | } 402 | if (post.userId !== ctx.user.id) { 403 | throw new TRPCError({ 404 | message: 'Cannot delete post owned by other user', 405 | code: 'FORBIDDEN', 406 | }); 407 | } 408 | 409 | database.posts = database.posts.filter((_post) => _post !== post); 410 | 411 | return null; 412 | }), 413 | }); 414 | 415 | export const appRouter = t.router({ 416 | auth: authRouter, 417 | users: usersRouter, 418 | posts: postsRouter, 419 | }); 420 | 421 | export type AppRouter = typeof appRouter; 422 | -------------------------------------------------------------------------------- /examples/with-nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-nuxtjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-nuxtjs 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/app.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({}); 2 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-nuxtjs", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^20.2.3", 13 | "nuxt": "^3.5.1" 14 | }, 15 | "dependencies": { 16 | "@trpc/server": "^10.27.1", 17 | "trpc-nuxt": "^0.10.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LilyRose2798/trpc-openapi/0f33bee81bc8c6df18752d4bd68ca4a5eab1be9a/examples/with-nuxtjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/api/[...trpc].ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiNuxtHandler } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter, createContext } from '../router'; 4 | 5 | export default createOpenApiNuxtHandler({ 6 | router: appRouter, 7 | createContext, 8 | }); 9 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/api/openapi.json.ts: -------------------------------------------------------------------------------- 1 | import { openApiDocument } from '../openapi'; 2 | 3 | export default defineEventHandler(() => { 4 | return openApiDocument; 5 | }); 6 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNuxtApiHandler } from 'trpc-nuxt'; 2 | 3 | import { appRouter, createContext } from '../../router'; 4 | 5 | export default createNuxtApiHandler({ 6 | router: appRouter, 7 | createContext, 8 | }); 9 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: number; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: 1234, 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: 9876, 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: 5678, 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Next.js', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /examples/with-serverless/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | .build -------------------------------------------------------------------------------- /examples/with-serverless/README.md: -------------------------------------------------------------------------------- 1 | # [**`@lilyrose2798/trpc-openapi`**](../../README.md) (with-serverless) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-serverless 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/with-serverless/handler.ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiAwsLambdaHandler } from '@lilyrose2798/trpc-openapi'; 2 | import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda'; 3 | 4 | import { openApiDocument } from './src/openapi'; 5 | import { appRouter, createContext } from './src/router'; 6 | 7 | // Handle incoming tRPC requests 8 | export const trpcHandler = awsLambdaRequestHandler({ 9 | router: appRouter, 10 | createContext, 11 | }); 12 | 13 | // Handle incoming OpenAPI requests 14 | export const trpcOpenApiHandler = createOpenApiAwsLambdaHandler({ 15 | router: appRouter, 16 | createContext, 17 | }); 18 | 19 | // Serve our OpenAPI schema 20 | // eslint-disable-next-line @typescript-eslint/require-await 21 | export const openApiJson = async () => { 22 | return { 23 | statusCode: 200, 24 | headers: { 'Content-Type': 'application/json' }, 25 | body: JSON.stringify(openApiDocument), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /examples/with-serverless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-serverless", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "rimraf .build && serverless offline" 7 | }, 8 | "dependencies": { 9 | "@trpc/server": "^10.27.1", 10 | "zod": "^3.21.4" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^20.2.3", 14 | "serverless": "^3.31.0", 15 | "serverless-offline": "^12.0.4", 16 | "serverless-plugin-typescript": "^2.1.4", 17 | "typescript": "^5.0.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-serverless/serverless.yml: -------------------------------------------------------------------------------- 1 | service: with-serverless 2 | frameworkVersion: '3' 3 | 4 | provider: 5 | name: aws 6 | runtime: nodejs14.x 7 | 8 | functions: 9 | openApiJson: 10 | handler: handler.openApiJson 11 | events: 12 | - httpApi: 13 | path: /api/openapi.json 14 | method: GET 15 | trpcApi: 16 | handler: handler.trpcHandler 17 | events: 18 | - httpApi: 19 | path: /api/trpc/{trpc+} 20 | method: any 21 | openApi: 22 | handler: handler.trpcOpenApiHandler 23 | events: 24 | - httpApi: 25 | path: /api/{trpc+} 26 | method: any 27 | 28 | plugins: 29 | - serverless-plugin-typescript 30 | - serverless-offline 31 | -------------------------------------------------------------------------------- /examples/with-serverless/src/database.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | email: string; 4 | passcode: string; 5 | name: string; 6 | }; 7 | 8 | export type Post = { 9 | id: string; 10 | content: string; 11 | userId: string; 12 | }; 13 | 14 | export const database: { users: User[]; posts: Post[] } = { 15 | users: [ 16 | { 17 | id: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 18 | email: 'lily@rose.place', 19 | passcode: '1234', 20 | name: 'Lily', 21 | }, 22 | { 23 | id: 'ea120573-2eb4-495e-be48-1b2debac2640', 24 | email: 'alex@example.com', 25 | passcode: '9876', 26 | name: 'Alex', 27 | }, 28 | { 29 | id: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 30 | email: 'sachin@example.com', 31 | passcode: '1234', 32 | name: 'Sachin', 33 | }, 34 | ], 35 | posts: [ 36 | { 37 | id: 'fc206d47-6d50-4b6a-9779-e9eeaee59aa4', 38 | content: 'Hello world', 39 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 40 | }, 41 | { 42 | id: 'a10479a2-a397-441e-b451-0b649d15cfd6', 43 | content: 'tRPC is so awesome', 44 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 45 | }, 46 | { 47 | id: 'de6867c7-13f1-4932-a69b-e96fd245ee72', 48 | content: 'Know the ropes', 49 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 50 | }, 51 | { 52 | id: '15a742b3-82f6-4fba-9fed-2d1328a4500a', 53 | content: 'Fight fire with fire', 54 | userId: 'ea120573-2eb4-495e-be48-1b2debac2640', 55 | }, 56 | { 57 | id: '31afa9ad-bc37-4e74-8d8b-1c1656184a33', 58 | content: 'I ate breakfast today', 59 | userId: '3dcb4a1f-0c91-42c5-834f-26d227c532e2', 60 | }, 61 | { 62 | id: '557cb26a-b26e-4329-a5b4-137327616ead', 63 | content: 'Par for the course', 64 | userId: '2ee1c07c-7537-48f5-b5d8-8740e165cd62', 65 | }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /examples/with-serverless/src/openapi.ts: -------------------------------------------------------------------------------- 1 | import { generateOpenApiDocument } from '@lilyrose2798/trpc-openapi'; 2 | 3 | import { appRouter } from './router'; 4 | 5 | // Generate OpenAPI schema document 6 | export const openApiDocument = generateOpenApiDocument(appRouter, { 7 | title: 'Example CRUD API', 8 | description: 'OpenAPI compliant REST API built using tRPC with Serverless', 9 | version: '1.0.0', 10 | baseUrl: 'http://localhost:3000/api', 11 | docsUrl: 'https://github.com/lilyrose2798/trpc-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-serverless/src/router.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMeta } from '@lilyrose2798/trpc-openapi'; 2 | import { TRPCError, initTRPC } from '@trpc/server'; 3 | import { APIGatewayEvent, CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda'; 4 | import jwt from 'jsonwebtoken'; 5 | import { v4 as uuid } from 'uuid'; 6 | import { z } from 'zod'; 7 | 8 | import { Post, User, database } from './database'; 9 | 10 | const jwtSecret = uuid(); 11 | 12 | export type Context = { 13 | user: User | null; 14 | requestId: string; 15 | }; 16 | 17 | const t = initTRPC 18 | .context() 19 | .meta() 20 | .create({ 21 | errorFormatter: ({ error, shape }) => { 22 | if (error.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production') { 23 | return { ...shape, message: 'Internal server error' }; 24 | } 25 | return shape; 26 | }, 27 | }); 28 | 29 | export const createContext = async ({ 30 | event, 31 | context, 32 | }: // eslint-disable-next-line @typescript-eslint/require-await 33 | CreateAWSLambdaContextOptions): Promise => { 34 | const requestId = uuid(); 35 | 36 | let user: User | null = null; 37 | 38 | try { 39 | if (event.headers.authorization) { 40 | const token = event.headers.authorization.split(' ')[1]; 41 | const userId = jwt.verify(token, jwtSecret) as string; 42 | if (userId) { 43 | user = database.users.find((_user) => _user.id === userId) ?? null; 44 | } 45 | } 46 | } catch (cause) { 47 | console.error(cause); 48 | } 49 | 50 | return { user, requestId }; 51 | }; 52 | 53 | const publicProcedure = t.procedure; 54 | const protectedProcedure = t.procedure.use(({ ctx, next }) => { 55 | if (!ctx.user) { 56 | throw new TRPCError({ 57 | message: 'User not found', 58 | code: 'UNAUTHORIZED', 59 | }); 60 | } 61 | return next({ ctx: { ...ctx, user: ctx.user } }); 62 | }); 63 | 64 | const authRouter = t.router({ 65 | register: publicProcedure 66 | .meta({ 67 | openapi: { 68 | method: 'POST', 69 | path: '/auth/register', 70 | tags: ['auth'], 71 | summary: 'Register as a new user', 72 | }, 73 | }) 74 | .input( 75 | z.object({ 76 | email: z.string().email(), 77 | passcode: z.string().regex(/^[0-9]{4}$/), 78 | name: z.string().min(3), 79 | }), 80 | ) 81 | .output( 82 | z.object({ 83 | user: z.object({ 84 | id: z.string().uuid(), 85 | email: z.string().email(), 86 | name: z.string().min(3), 87 | }), 88 | }), 89 | ) 90 | .mutation(({ input }) => { 91 | let user = database.users.find((_user) => _user.email === input.email); 92 | 93 | if (user) { 94 | throw new TRPCError({ 95 | message: 'User with email already exists', 96 | code: 'UNAUTHORIZED', 97 | }); 98 | } 99 | 100 | user = { 101 | id: uuid(), 102 | email: input.email, 103 | passcode: input.passcode, 104 | name: input.name, 105 | }; 106 | 107 | database.users.push(user); 108 | 109 | return { user: { id: user.id, email: user.email, name: user.name } }; 110 | }), 111 | login: publicProcedure 112 | .meta({ 113 | openapi: { 114 | method: 'POST', 115 | path: '/auth/login', 116 | tags: ['auth'], 117 | summary: 'Login as an existing user', 118 | }, 119 | }) 120 | .input( 121 | z.object({ 122 | email: z.string().email(), 123 | passcode: z.string().regex(/^[0-9]{4}$/), 124 | }), 125 | ) 126 | .output( 127 | z.object({ 128 | token: z.string(), 129 | }), 130 | ) 131 | .mutation(({ input }) => { 132 | const user = database.users.find((_user) => _user.email === input.email); 133 | 134 | if (!user) { 135 | throw new TRPCError({ 136 | message: 'User with email not found', 137 | code: 'UNAUTHORIZED', 138 | }); 139 | } 140 | if (user.passcode !== input.passcode) { 141 | throw new TRPCError({ 142 | message: 'Passcode was incorrect', 143 | code: 'UNAUTHORIZED', 144 | }); 145 | } 146 | 147 | return { 148 | token: jwt.sign(user.id, jwtSecret), 149 | }; 150 | }), 151 | }); 152 | 153 | const usersRouter = t.router({ 154 | getUsers: publicProcedure 155 | .meta({ 156 | openapi: { 157 | method: 'GET', 158 | path: '/users', 159 | tags: ['users'], 160 | summary: 'Read all users', 161 | }, 162 | }) 163 | .input(z.void()) 164 | .output( 165 | z.object({ 166 | users: z.array( 167 | z.object({ 168 | id: z.string().uuid(), 169 | email: z.string().email(), 170 | name: z.string(), 171 | }), 172 | ), 173 | }), 174 | ) 175 | .query(() => { 176 | const users = database.users.map((user) => ({ 177 | id: user.id, 178 | email: user.email, 179 | name: user.name, 180 | })); 181 | 182 | return { users }; 183 | }), 184 | getUserById: publicProcedure 185 | .meta({ 186 | openapi: { 187 | method: 'GET', 188 | path: '/users/{id}', 189 | tags: ['users'], 190 | summary: 'Read a user by id', 191 | }, 192 | }) 193 | .input( 194 | z.object({ 195 | id: z.string().uuid(), 196 | }), 197 | ) 198 | .output( 199 | z.object({ 200 | user: z.object({ 201 | id: z.string().uuid(), 202 | email: z.string().email(), 203 | name: z.string(), 204 | }), 205 | }), 206 | ) 207 | .query(({ input }) => { 208 | const user = database.users.find((_user) => _user.id === input.id); 209 | 210 | if (!user) { 211 | throw new TRPCError({ 212 | message: 'User not found', 213 | code: 'NOT_FOUND', 214 | }); 215 | } 216 | 217 | return { user }; 218 | }), 219 | }); 220 | 221 | const postsRouter = t.router({ 222 | getPosts: publicProcedure 223 | .meta({ 224 | openapi: { 225 | method: 'GET', 226 | path: '/posts', 227 | tags: ['posts'], 228 | summary: 'Read all posts', 229 | }, 230 | }) 231 | .input( 232 | z.object({ 233 | userId: z.string().uuid().optional(), 234 | }), 235 | ) 236 | .output( 237 | z.object({ 238 | posts: z.array( 239 | z.object({ 240 | id: z.string().uuid(), 241 | content: z.string(), 242 | userId: z.string().uuid(), 243 | }), 244 | ), 245 | }), 246 | ) 247 | .query(({ input }) => { 248 | let posts: Post[] = database.posts; 249 | 250 | if (input.userId) { 251 | posts = posts.filter((post) => { 252 | return post.userId === input.userId; 253 | }); 254 | } 255 | 256 | return { posts }; 257 | }), 258 | getPostById: publicProcedure 259 | .meta({ 260 | openapi: { 261 | method: 'GET', 262 | path: '/posts/{id}', 263 | tags: ['posts'], 264 | summary: 'Read a post by id', 265 | }, 266 | }) 267 | .input( 268 | z.object({ 269 | id: z.string().uuid(), 270 | }), 271 | ) 272 | .output( 273 | z.object({ 274 | post: z.object({ 275 | id: z.string().uuid(), 276 | content: z.string(), 277 | userId: z.string().uuid(), 278 | }), 279 | }), 280 | ) 281 | .query(({ input }) => { 282 | const post = database.posts.find((_post) => _post.id === input.id); 283 | 284 | if (!post) { 285 | throw new TRPCError({ 286 | message: 'Post not found', 287 | code: 'NOT_FOUND', 288 | }); 289 | } 290 | 291 | return { post }; 292 | }), 293 | createPost: protectedProcedure 294 | .meta({ 295 | openapi: { 296 | method: 'POST', 297 | path: '/posts', 298 | tags: ['posts'], 299 | protect: true, 300 | summary: 'Create a new post', 301 | }, 302 | }) 303 | .input( 304 | z.object({ 305 | content: z.string().min(1).max(140), 306 | }), 307 | ) 308 | .output( 309 | z.object({ 310 | post: z.object({ 311 | id: z.string().uuid(), 312 | content: z.string(), 313 | userId: z.string().uuid(), 314 | }), 315 | }), 316 | ) 317 | .mutation(({ input, ctx }) => { 318 | const post: Post = { 319 | id: uuid(), 320 | content: input.content, 321 | userId: ctx.user.id, 322 | }; 323 | 324 | database.posts.push(post); 325 | 326 | return { post }; 327 | }), 328 | updatePostById: protectedProcedure 329 | .meta({ 330 | openapi: { 331 | method: 'PUT', 332 | path: '/posts/{id}', 333 | tags: ['posts'], 334 | protect: true, 335 | summary: 'Update an existing post', 336 | }, 337 | }) 338 | .input( 339 | z.object({ 340 | id: z.string().uuid(), 341 | content: z.string().min(1), 342 | }), 343 | ) 344 | .output( 345 | z.object({ 346 | post: z.object({ 347 | id: z.string().uuid(), 348 | content: z.string(), 349 | userId: z.string().uuid(), 350 | }), 351 | }), 352 | ) 353 | .mutation(({ input, ctx }) => { 354 | const post = database.posts.find((_post) => _post.id === input.id); 355 | 356 | if (!post) { 357 | throw new TRPCError({ 358 | message: 'Post not found', 359 | code: 'NOT_FOUND', 360 | }); 361 | } 362 | if (post.userId !== ctx.user.id) { 363 | throw new TRPCError({ 364 | message: 'Cannot edit post owned by other user', 365 | code: 'FORBIDDEN', 366 | }); 367 | } 368 | 369 | post.content = input.content; 370 | 371 | return { post }; 372 | }), 373 | deletePostById: protectedProcedure 374 | .meta({ 375 | openapi: { 376 | method: 'DELETE', 377 | path: '/posts/{id}', 378 | tags: ['posts'], 379 | protect: true, 380 | summary: 'Delete a post', 381 | }, 382 | }) 383 | .input( 384 | z.object({ 385 | id: z.string().uuid(), 386 | }), 387 | ) 388 | .output(z.null()) 389 | .mutation(({ input, ctx }) => { 390 | const post = database.posts.find((_post) => _post.id === input.id); 391 | 392 | if (!post) { 393 | throw new TRPCError({ 394 | message: 'Post not found', 395 | code: 'NOT_FOUND', 396 | }); 397 | } 398 | if (post.userId !== ctx.user.id) { 399 | throw new TRPCError({ 400 | message: 'Cannot delete post owned by other user', 401 | code: 'FORBIDDEN', 402 | }); 403 | } 404 | 405 | database.posts = database.posts.filter((_post) => _post !== post); 406 | 407 | return null; 408 | }), 409 | }); 410 | 411 | export const appRouter = t.router({ 412 | auth: authRouter, 413 | users: usersRouter, 414 | posts: postsRouter, 415 | }); 416 | 417 | export type AppRouter = typeof appRouter; 418 | -------------------------------------------------------------------------------- /examples/with-serverless/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "exclude": ["node_modules"] 102 | } 103 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 4 | module.exports = { 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | rootDir: './test', 8 | snapshotFormat: { 9 | escapeString: true, 10 | printBasicPrototype: true, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lilyrose2798/trpc-openapi", 3 | "version": "1.4.1", 4 | "description": "tRPC OpenAPI", 5 | "author": "LilyRose2798 ", 6 | "private": false, 7 | "license": "MIT", 8 | "keywords": [ 9 | "trpc", 10 | "openapi", 11 | "swagger" 12 | ], 13 | "homepage": "https://github.com/lilyrose2798/trpc-openapi", 14 | "repository": "github:lilyrose2798/trpc-openapi", 15 | "bugs": "https://github.com/lilyrose2798/trpc-openapi/issues", 16 | "main": "dist/cjs/index.js", 17 | "module": "dist/esm/index.mjs", 18 | "typings": "dist/cjs/index.d.ts", 19 | "files": [ 20 | "dist/cjs", 21 | "dist/esm" 22 | ], 23 | "exports": { 24 | ".": { 25 | "require": "./dist/cjs/index.js", 26 | "import": "./dist/esm/index.mjs", 27 | "types": "./dist/cjs/index.d.ts" 28 | } 29 | }, 30 | "workspaces": [ 31 | ".", 32 | "examples/with-nextjs", 33 | "examples/with-express", 34 | "examples/with-interop", 35 | "examples/with-serverless", 36 | "examples/with-fastify", 37 | "examples/with-nuxtjs" 38 | ], 39 | "scripts": { 40 | "test": "tsc --noEmit && jest --verbose", 41 | "build": "rimraf dist && npm run build:cjs && npm run build:esm", 42 | "build:cjs": "tsc -p tsconfig.build.cjs.json", 43 | "build:esm": "tsc -p tsconfig.build.esm.json", 44 | "postbuild": "node rename.js" 45 | }, 46 | "peerDependencies": { 47 | "@trpc/server": "^10.0.0", 48 | "zod": "^3.14.4" 49 | }, 50 | "dependencies": { 51 | "co-body": "^6.1.0", 52 | "h3": "^1.6.4", 53 | "lodash.clonedeep": "^4.5.0", 54 | "node-mocks-http": "^1.12.2", 55 | "openapi3-ts": "^4.1.2", 56 | "zod-openapi": "^2.10.0" 57 | }, 58 | "devDependencies": { 59 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 60 | "@trpc/client": "^10.27.1", 61 | "@types/aws-lambda": "^8.10.115", 62 | "@types/co-body": "^6.1.0", 63 | "@types/express": "^4.17.17", 64 | "@types/jest": "^29.5.1", 65 | "@types/lodash.clonedeep": "^4.5.7", 66 | "@types/node": "^20.2.3", 67 | "@types/node-fetch": "^2.6.4", 68 | "@typescript-eslint/eslint-plugin": "^5.59.7", 69 | "@typescript-eslint/parser": "^5.59.7", 70 | "aws-lambda": "^1.0.7", 71 | "eslint": "^8.41.0", 72 | "eslint-config-prettier": "^8.8.0", 73 | "eslint-plugin-import": "^2.27.5", 74 | "eslint-plugin-prettier": "^4.2.1", 75 | "eslint-plugin-promise": "^6.1.1", 76 | "express": "^4.18.2", 77 | "fastify": "^4.17.0", 78 | "jest": "^29.5.0", 79 | "next": "^13.4.3", 80 | "node-fetch": "^2.6.11", 81 | "openapi-schema-validator": "^12.1.1", 82 | "prettier": "^2.8.8", 83 | "rimraf": "^5.0.1", 84 | "superjson": "^1.12.3", 85 | "ts-jest": "^29.1.0", 86 | "ts-node": "^10.9.1", 87 | "typescript": "^5.0.4" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type import("prettier").Options */ 4 | module.exports = { 5 | printWidth: 100, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: true, 9 | singleQuote: true, 10 | trailingComma: 'all', 11 | endOfLine: 'lf', 12 | importOrder: ['__', '', '^[./]'], 13 | importOrderSeparation: true, 14 | importOrderSortSpecifiers: true, 15 | }; 16 | -------------------------------------------------------------------------------- /rename.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const baseDirectoryPath = path.join(__dirname, 'dist/esm'); 5 | 6 | // Function to append .mjs to relative module imports and then rename the file to .mjs 7 | function modifyFileExtensions(filePath) { 8 | const data = fs.readFileSync(filePath, 'utf8'); 9 | const modifiedData = data.replace(/from\s+['"]((?:\.\/|\.\.\/)[^'"]+)['"]/g, (match, p1) => { 10 | // Skip modification if the import statement already ends with .mjs or is a URL 11 | if (p1.endsWith('.mjs') || p1.startsWith('http:') || p1.startsWith('https:')) { 12 | return match; 13 | } 14 | // Check if the path refers to a directory's index file 15 | const fullPath = path.resolve(path.dirname(filePath), p1); 16 | if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { 17 | return `from '${p1}/index.mjs'`; 18 | } 19 | return `from '${p1}.mjs'`; 20 | }); 21 | 22 | fs.writeFileSync(filePath, modifiedData, 'utf8'); 23 | 24 | // Rename the file to .mjs 25 | const newFilePath = filePath.replace(/\.js$/, '.mjs'); 26 | fs.renameSync(filePath, newFilePath); 27 | } 28 | 29 | // Recursive function to process all .js files in a directory 30 | function processDirectory(directoryPath) { 31 | fs.readdir(directoryPath, { withFileTypes: true }, (err, entries) => { 32 | if (err) { 33 | console.log('Unable to scan directory:', err); 34 | return; 35 | } 36 | 37 | entries.forEach((entry) => { 38 | const fullPath = path.join(directoryPath, entry.name); 39 | if (entry.isDirectory()) { 40 | processDirectory(fullPath); 41 | } else if (entry.isFile() && entry.name.endsWith('.js')) { 42 | modifyFileExtensions(fullPath); 43 | } 44 | }); 45 | }); 46 | } 47 | 48 | // Start the processing with the base directory 49 | processDirectory(baseDirectoryPath); 50 | -------------------------------------------------------------------------------- /src/adapters/aws-lambda.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { 3 | APIGatewayEvent, 4 | AWSLambdaOptions, 5 | UNKNOWN_PAYLOAD_FORMAT_VERSION_ERROR_MESSAGE, 6 | getHTTPMethod, 7 | getPath, 8 | isPayloadV1, 9 | isPayloadV2, 10 | transformHeaders, 11 | } from '@trpc/server/adapters/aws-lambda'; 12 | import type { NodeHTTPRequest } from '@trpc/server/dist/adapters/node-http'; 13 | import type { Context as APIGWContext } from 'aws-lambda'; 14 | import { EventEmitter } from 'events'; 15 | import type { RequestMethod } from 'node-mocks-http'; 16 | import { createRequest, createResponse } from 'node-mocks-http'; 17 | 18 | import type { OpenApiErrorResponse, OpenApiRouter } from '../types'; 19 | import { createOpenApiNodeHttpHandler } from './node-http/core'; 20 | import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from './node-http/errors'; 21 | 22 | export type CreateOpenApiAwsLambdaHandlerOptions< 23 | TRouter extends OpenApiRouter, 24 | TEvent extends APIGatewayEvent, 25 | > = Pick< 26 | AWSLambdaOptions, 27 | 'router' | 'createContext' | 'responseMeta' | 'onError' 28 | >; 29 | 30 | const createMockNodeHTTPPath = (event: APIGatewayEvent) => { 31 | let path = getPath(event); 32 | if (!path.startsWith('/')) { 33 | path = `/${path}`; 34 | } 35 | return path; 36 | }; 37 | 38 | const createMockNodeHTTPRequest = (path: string, event: APIGatewayEvent): NodeHTTPRequest => { 39 | const url = event.requestContext.domainName 40 | ? `https://${event.requestContext.domainName}${path}` 41 | : path; 42 | 43 | const method = getHTTPMethod(event).toUpperCase() as RequestMethod; 44 | 45 | let body = undefined; 46 | const contentType = 47 | event.headers[ 48 | Object.keys(event.headers).find((key) => key.toLowerCase() === 'content-type') ?? '' 49 | ]; 50 | if (contentType === 'application/json') { 51 | try { 52 | if (event.body) { 53 | body = JSON.parse(event.body); 54 | } 55 | } catch (cause) { 56 | throw new TRPCError({ 57 | message: 'Failed to parse request body', 58 | code: 'PARSE_ERROR', 59 | cause, 60 | }); 61 | } 62 | } 63 | if (contentType === 'application/x-www-form-urlencoded') { 64 | try { 65 | if (event.body) { 66 | const searchParamsString = event.isBase64Encoded 67 | ? Buffer.from(event.body, 'base64').toString('utf-8') 68 | : event.body; 69 | const searchParams = new URLSearchParams(searchParamsString); 70 | body = {} as Record; 71 | for (const [key, value] of searchParams.entries()) { 72 | body[key] = value; 73 | } 74 | } 75 | } catch (cause) { 76 | throw new TRPCError({ 77 | message: 'Failed to parse request body', 78 | code: 'PARSE_ERROR', 79 | cause, 80 | }); 81 | } 82 | } 83 | 84 | return createRequest({ 85 | url, 86 | method, 87 | query: event.queryStringParameters || undefined, 88 | headers: event.headers, 89 | body, 90 | }); 91 | }; 92 | 93 | const createMockNodeHTTPResponse = () => { 94 | return createResponse({ eventEmitter: EventEmitter }); 95 | }; 96 | 97 | export const createOpenApiAwsLambdaHandler = < 98 | TRouter extends OpenApiRouter, 99 | TEvent extends APIGatewayEvent, 100 | >( 101 | opts: CreateOpenApiAwsLambdaHandlerOptions, 102 | ) => { 103 | return async (event: TEvent, context: APIGWContext) => { 104 | let path: string | undefined; 105 | try { 106 | if (!isPayloadV1(event) && !isPayloadV2(event)) { 107 | throw new TRPCError({ 108 | message: UNKNOWN_PAYLOAD_FORMAT_VERSION_ERROR_MESSAGE, 109 | code: 'INTERNAL_SERVER_ERROR', 110 | }); 111 | } 112 | 113 | const createContext = async () => opts.createContext?.({ event, context }); 114 | const openApiHttpHandler = createOpenApiNodeHttpHandler({ ...opts, createContext } as any); 115 | 116 | path = createMockNodeHTTPPath(event); 117 | const req = createMockNodeHTTPRequest(path, event); 118 | const res = createMockNodeHTTPResponse(); 119 | 120 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 121 | // @ts-ignore 122 | await openApiHttpHandler(req, res); 123 | 124 | return { 125 | statusCode: res.statusCode, 126 | headers: transformHeaders(res._getHeaders() || {}), 127 | body: res._getData(), 128 | }; 129 | } catch (cause) { 130 | const error = getErrorFromUnknown(cause); 131 | 132 | opts.onError?.({ 133 | error, 134 | type: 'unknown', 135 | path, 136 | input: undefined, 137 | ctx: undefined, 138 | req: event, 139 | }); 140 | 141 | const meta = opts.responseMeta?.({ 142 | type: 'unknown', 143 | paths: [path as unknown as string], 144 | ctx: undefined, 145 | data: [undefined as unknown as any], 146 | errors: [error], 147 | }); 148 | 149 | const errorShape = opts.router.getErrorShape({ 150 | error, 151 | type: 'unknown', 152 | path, 153 | input: undefined, 154 | ctx: undefined, 155 | }); 156 | 157 | const statusCode = meta?.status ?? TRPC_ERROR_CODE_HTTP_STATUS[error.code] ?? 500; 158 | const headers = { 'content-type': 'application/json', ...(meta?.headers ?? {}) }; 159 | const body: OpenApiErrorResponse = { 160 | message: errorShape?.message ?? error.message ?? 'An error occurred', 161 | code: error.code, 162 | }; 163 | 164 | return { 165 | statusCode, 166 | headers, 167 | body: JSON.stringify(body), 168 | }; 169 | } 170 | }; 171 | }; 172 | -------------------------------------------------------------------------------- /src/adapters/express.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { OpenApiRouter } from '../types'; 4 | import { 5 | CreateOpenApiNodeHttpHandlerOptions, 6 | createOpenApiNodeHttpHandler, 7 | } from './node-http/core'; 8 | 9 | export type CreateOpenApiExpressMiddlewareOptions = 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 11 | // @ts-ignore 12 | CreateOpenApiNodeHttpHandlerOptions; 13 | 14 | export const createOpenApiExpressMiddleware = ( 15 | opts: CreateOpenApiExpressMiddlewareOptions, 16 | ) => { 17 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 18 | 19 | return async (req: Request, res: Response) => { 20 | await openApiHttpHandler(req, res); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/adapters/fastify.ts: -------------------------------------------------------------------------------- 1 | import { AnyRouter } from '@trpc/server'; 2 | import { FastifyInstance } from 'fastify'; 3 | 4 | import { OpenApiRouter } from '../types'; 5 | import { 6 | CreateOpenApiNodeHttpHandlerOptions, 7 | createOpenApiNodeHttpHandler, 8 | } from './node-http/core'; 9 | 10 | export type CreateOpenApiFastifyPluginOptions = 11 | CreateOpenApiNodeHttpHandlerOptions & { 12 | basePath?: `/${string}`; 13 | }; 14 | 15 | export function fastifyTRPCOpenApiPlugin( 16 | fastify: FastifyInstance, 17 | opts: CreateOpenApiFastifyPluginOptions, 18 | done: (err?: Error) => void, 19 | ) { 20 | let prefix = opts.basePath ?? ''; 21 | 22 | // if prefix ends with a slash, remove it 23 | if (prefix.endsWith('/')) { 24 | prefix = prefix.slice(0, -1); 25 | } 26 | 27 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 28 | 29 | fastify.all(`${prefix}/*`, async (request, reply) => { 30 | const prefixRemovedFromUrl = request.url.replace(fastify.prefix, '').replace(prefix, ''); 31 | request.raw.url = prefixRemovedFromUrl; 32 | return await openApiHttpHandler( 33 | request, 34 | Object.assign(reply, { 35 | setHeader: (key: string, value: string | number | readonly string[]) => { 36 | if (Array.isArray(value)) { 37 | value.forEach((v) => reply.header(key, v)); 38 | return reply; 39 | } 40 | 41 | return reply.header(key, value); 42 | }, 43 | end: (body: any) => reply.send(body), // eslint-disable-line @typescript-eslint/no-explicit-any 44 | }), 45 | ); 46 | }); 47 | 48 | done(); 49 | } 50 | -------------------------------------------------------------------------------- /src/adapters/fetch.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { FetchHandlerOptions } from '@trpc/server/adapters/fetch'; 3 | import { IncomingMessage, ServerResponse } from 'http'; 4 | 5 | import { OpenApiRouter } from '../types'; 6 | import { 7 | CreateOpenApiNodeHttpHandlerOptions, 8 | createOpenApiNodeHttpHandler, 9 | } from './node-http/core'; 10 | 11 | export type CreateOpenApiFetchHandlerOptions = Omit< 12 | FetchHandlerOptions, 13 | 'batching' 14 | > & { 15 | req: Request; 16 | endpoint: `/${string}`; 17 | }; 18 | 19 | const getUrlEncodedBody = async (req: Request) => { 20 | const params = new URLSearchParams(await req.text()); 21 | 22 | const data: Record = {}; 23 | 24 | for (const key of params.keys()) { 25 | data[key] = params.getAll(key); 26 | } 27 | 28 | return data; 29 | }; 30 | 31 | // co-body does not parse Request body correctly 32 | const getRequestBody = async (req: Request) => { 33 | try { 34 | if (req.headers.get('content-type')?.includes('application/json')) { 35 | return { 36 | isValid: true, 37 | // use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON 38 | data: JSON.parse(await req.text()), 39 | }; 40 | } 41 | 42 | if (req.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) { 43 | return { 44 | isValid: true, 45 | data: await getUrlEncodedBody(req), 46 | }; 47 | } 48 | 49 | return { 50 | isValid: true, 51 | data: req.body, 52 | }; 53 | } catch (err) { 54 | return { 55 | isValid: false, 56 | cause: err, 57 | }; 58 | } 59 | }; 60 | 61 | const createRequestProxy = async (req: Request, url?: string) => { 62 | const body = await getRequestBody(req); 63 | 64 | return new Proxy(req, { 65 | get: (target, prop) => { 66 | if (prop === 'url') { 67 | return url ? url : target.url; 68 | } 69 | 70 | if (prop === 'headers') { 71 | return new Proxy(target.headers, { 72 | get: (target, prop) => { 73 | return target.get(prop.toString()); 74 | }, 75 | }); 76 | } 77 | 78 | if (prop === 'body') { 79 | if (!body.isValid) { 80 | throw new TRPCError({ 81 | code: 'PARSE_ERROR', 82 | message: 'Failed to parse request body', 83 | cause: body.cause, 84 | }); 85 | } 86 | 87 | return body.data; 88 | } 89 | 90 | return target[prop as keyof typeof target]; 91 | }, 92 | }); 93 | }; 94 | 95 | export const createOpenApiFetchHandler = async ( 96 | opts: CreateOpenApiFetchHandlerOptions, 97 | ): Promise => { 98 | const resHeaders = new Headers(); 99 | const url = new URL(opts.req.url.replace(opts.endpoint, '')); 100 | const req: Request = await createRequestProxy(opts.req, url.toString()); 101 | 102 | const createContext = () => { 103 | if (opts.createContext) { 104 | return opts.createContext({ req: opts.req, resHeaders }); 105 | } 106 | return () => ({}); 107 | }; 108 | 109 | const openApiHttpHandler = createOpenApiNodeHttpHandler({ 110 | router: opts.router, 111 | createContext, 112 | onError: opts.onError, 113 | responseMeta: opts.responseMeta, 114 | } as CreateOpenApiNodeHttpHandlerOptions); 115 | 116 | return new Promise((resolve) => { 117 | let statusCode: number | undefined; 118 | 119 | return openApiHttpHandler( 120 | req as unknown as IncomingMessage, 121 | 122 | { 123 | setHeader: (key: string, value: string | readonly string[]) => { 124 | if (typeof value === 'string') { 125 | resHeaders.set(key, value); 126 | } else { 127 | for (const v of value) { 128 | resHeaders.append(key, v); 129 | } 130 | } 131 | }, 132 | get statusCode() { 133 | return statusCode; 134 | }, 135 | set statusCode(code: number | undefined) { 136 | statusCode = code; 137 | }, 138 | end: (body: string) => { 139 | resolve( 140 | new Response(body, { 141 | headers: resHeaders, 142 | status: statusCode, 143 | }), 144 | ); 145 | }, 146 | } as unknown as ServerResponse, 147 | ); 148 | }); 149 | }; 150 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aws-lambda'; 2 | export * from './standalone'; 3 | export * from './express'; 4 | export * from './next'; 5 | export * from './fastify'; 6 | export * from './fetch'; 7 | export * from './nuxt'; 8 | -------------------------------------------------------------------------------- /src/adapters/next.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | import { OpenApiErrorResponse, OpenApiRouter } from '../types'; 5 | import { normalizePath } from '../utils/path'; 6 | import { 7 | CreateOpenApiNodeHttpHandlerOptions, 8 | createOpenApiNodeHttpHandler, 9 | } from './node-http/core'; 10 | 11 | export type CreateOpenApiNextHandlerOptions = Omit< 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | CreateOpenApiNodeHttpHandlerOptions, 15 | 'maxBodySize' 16 | >; 17 | 18 | export const createOpenApiNextHandler = ( 19 | opts: CreateOpenApiNextHandlerOptions, 20 | ) => { 21 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 22 | 23 | return async (req: NextApiRequest, res: NextApiResponse) => { 24 | let pathname: string | null = null; 25 | if (typeof req.query.trpc === 'string') { 26 | pathname = req.query.trpc; 27 | } else if (Array.isArray(req.query.trpc)) { 28 | pathname = req.query.trpc.join('/'); 29 | } 30 | 31 | if (pathname === null) { 32 | const error = new TRPCError({ 33 | message: 'Query "trpc" not found - is the `trpc-openapi` file named `[...trpc].ts`?', 34 | code: 'INTERNAL_SERVER_ERROR', 35 | }); 36 | 37 | opts.onError?.({ 38 | error, 39 | type: 'unknown', 40 | path: undefined, 41 | input: undefined, 42 | ctx: undefined, 43 | req, 44 | }); 45 | 46 | res.statusCode = 500; 47 | res.setHeader('Content-Type', 'application/json'); 48 | const body: OpenApiErrorResponse = { 49 | message: error.message, 50 | code: error.code, 51 | }; 52 | res.end(JSON.stringify(body)); 53 | 54 | return; 55 | } 56 | 57 | req.url = normalizePath(pathname); 58 | await openApiHttpHandler(req, res); 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/adapters/node-http/core.ts: -------------------------------------------------------------------------------- 1 | import { AnyProcedure, TRPCError } from '@trpc/server'; 2 | import { 3 | NodeHTTPHandlerOptions, 4 | NodeHTTPRequest, 5 | NodeHTTPResponse, 6 | } from '@trpc/server/dist/adapters/node-http'; 7 | import cloneDeep from 'lodash.clonedeep'; 8 | import { ZodError, ZodTypeAny, z } from 'zod'; 9 | 10 | import { generateOpenApiDocument } from '../../generator'; 11 | import { 12 | OpenApiErrorResponse, 13 | OpenApiMethod, 14 | OpenApiResponse, 15 | OpenApiRouter, 16 | OpenApiSuccessResponse, 17 | } from '../../types'; 18 | import { acceptsRequestBody } from '../../utils/method'; 19 | import { normalizePath } from '../../utils/path'; 20 | import { getInputOutputParsers } from '../../utils/procedure'; 21 | import { 22 | coerceSchema, 23 | instanceofZodTypeLikeVoid, 24 | instanceofZodTypeObject, 25 | unwrapZodType, 26 | zodSupportsCoerce, 27 | } from '../../utils/zod'; 28 | import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from './errors'; 29 | import { getBody, getQuery } from './input'; 30 | import { createProcedureCache } from './procedures'; 31 | 32 | export type CreateOpenApiNodeHttpHandlerOptions< 33 | TRouter extends OpenApiRouter, 34 | TRequest extends NodeHTTPRequest, 35 | TResponse extends NodeHTTPResponse, 36 | > = Pick< 37 | NodeHTTPHandlerOptions, 38 | 'router' | 'createContext' | 'responseMeta' | 'onError' | 'maxBodySize' 39 | >; 40 | 41 | export type OpenApiNextFunction = () => void; 42 | 43 | export const createOpenApiNodeHttpHandler = < 44 | TRouter extends OpenApiRouter, 45 | TRequest extends NodeHTTPRequest, 46 | TResponse extends NodeHTTPResponse, 47 | >( 48 | opts: CreateOpenApiNodeHttpHandlerOptions, 49 | ) => { 50 | const router = cloneDeep(opts.router); 51 | 52 | // Validate router 53 | if (process.env.NODE_ENV !== 'production') { 54 | generateOpenApiDocument(router, { title: '', version: '', baseUrl: '' }); 55 | } 56 | 57 | const { createContext, responseMeta, onError, maxBodySize } = opts; 58 | const getProcedure = createProcedureCache(router); 59 | 60 | return async (req: TRequest, res: TResponse, next?: OpenApiNextFunction) => { 61 | const sendResponse = ( 62 | statusCode: number, 63 | headers: Record, 64 | body: OpenApiResponse | undefined, 65 | ) => { 66 | res.statusCode = statusCode; 67 | res.setHeader('Content-Type', 'application/json'); 68 | for (const [key, value] of Object.entries(headers)) { 69 | if (typeof value !== 'undefined') { 70 | res.setHeader(key, value); 71 | } 72 | } 73 | res.end(JSON.stringify(body)); 74 | }; 75 | 76 | const method = req.method! as OpenApiMethod & 'HEAD'; 77 | const reqUrl = req.url!; 78 | const url = new URL(reqUrl.startsWith('/') ? `http://127.0.0.1${reqUrl}` : reqUrl); 79 | const path = normalizePath(url.pathname); 80 | const { procedure, pathInput } = getProcedure(method, path) ?? {}; 81 | 82 | let input: any = undefined; 83 | let ctx: any = undefined; 84 | let data: any = undefined; 85 | 86 | try { 87 | if (!procedure) { 88 | if (next) { 89 | return next(); 90 | } 91 | 92 | // Can be used for warmup 93 | if (method === 'HEAD') { 94 | sendResponse(204, {}, undefined); 95 | return; 96 | } 97 | 98 | throw new TRPCError({ 99 | message: 'Not found', 100 | code: 'NOT_FOUND', 101 | }); 102 | } 103 | 104 | const useBody = acceptsRequestBody(method); 105 | const inputParser = getInputOutputParsers(procedure.procedure).inputParser as ZodTypeAny; 106 | const unwrappedSchema = unwrapZodType(inputParser, true); 107 | 108 | // input should stay undefined if z.void() 109 | if (!instanceofZodTypeLikeVoid(unwrappedSchema)) { 110 | input = { 111 | ...(useBody ? await getBody(req, maxBodySize) : getQuery(req, url)), 112 | ...pathInput, 113 | }; 114 | } 115 | 116 | // if supported, coerce all string values to correct types 117 | if (zodSupportsCoerce && instanceofZodTypeObject(unwrappedSchema)) 118 | coerceSchema(unwrappedSchema); 119 | 120 | ctx = await createContext?.({ req, res }); 121 | const caller = router.createCaller(ctx); 122 | 123 | const segments = procedure.path.split('.'); 124 | const procedureFn = segments.reduce((acc, curr) => acc[curr], caller as any) as AnyProcedure; 125 | 126 | data = await procedureFn(input); 127 | 128 | const meta = responseMeta?.({ 129 | type: procedure.type, 130 | paths: [procedure.path], 131 | ctx, 132 | data: [data], 133 | errors: [], 134 | }); 135 | 136 | const statusCode = meta?.status ?? 200; 137 | const headers = meta?.headers ?? {}; 138 | const body: OpenApiSuccessResponse = data; 139 | sendResponse(statusCode, headers, body); 140 | } catch (cause) { 141 | const error = getErrorFromUnknown(cause); 142 | 143 | onError?.({ 144 | error, 145 | type: procedure?.type ?? 'unknown', 146 | path: procedure?.path, 147 | input, 148 | ctx, 149 | req, 150 | }); 151 | 152 | const meta = responseMeta?.({ 153 | type: procedure?.type ?? 'unknown', 154 | paths: procedure?.path ? [procedure?.path] : undefined, 155 | ctx, 156 | data: [data], 157 | errors: [error], 158 | }); 159 | 160 | const errorShape = router.getErrorShape({ 161 | error, 162 | type: procedure?.type ?? 'unknown', 163 | path: procedure?.path, 164 | input, 165 | ctx, 166 | }); 167 | 168 | const isInputValidationError = 169 | error.code === 'BAD_REQUEST' && 170 | error.cause instanceof Error && 171 | error.cause.name === 'ZodError'; 172 | 173 | const statusCode = meta?.status ?? TRPC_ERROR_CODE_HTTP_STATUS[error.code] ?? 500; 174 | const headers = meta?.headers ?? {}; 175 | const body: OpenApiErrorResponse = { 176 | ...errorShape, // Pass the error through 177 | message: isInputValidationError 178 | ? 'Input validation failed' 179 | : errorShape?.message ?? error.message ?? 'An error occurred', 180 | code: error.code, 181 | issues: isInputValidationError ? (error.cause as ZodError).errors : undefined, 182 | }; 183 | sendResponse(statusCode, headers, body); 184 | } 185 | }; 186 | }; 187 | -------------------------------------------------------------------------------- /src/adapters/node-http/errors.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | 3 | export const TRPC_ERROR_CODE_HTTP_STATUS: Record = { 4 | PARSE_ERROR: 400, 5 | BAD_REQUEST: 400, 6 | NOT_FOUND: 404, 7 | INTERNAL_SERVER_ERROR: 500, 8 | UNAUTHORIZED: 401, 9 | FORBIDDEN: 403, 10 | TIMEOUT: 408, 11 | CONFLICT: 409, 12 | CLIENT_CLOSED_REQUEST: 499, 13 | PRECONDITION_FAILED: 412, 14 | PAYLOAD_TOO_LARGE: 413, 15 | METHOD_NOT_SUPPORTED: 405, 16 | TOO_MANY_REQUESTS: 429, 17 | UNPROCESSABLE_CONTENT: 422, 18 | NOT_IMPLEMENTED: 501, 19 | }; 20 | 21 | export const HTTP_STATUS_TRPC_ERROR_CODE: Record = { 22 | 400: 'BAD_REQUEST', 23 | 404: 'NOT_FOUND', 24 | 500: 'INTERNAL_SERVER_ERROR', 25 | 401: 'UNAUTHORIZED', 26 | 403: 'FORBIDDEN', 27 | 408: 'TIMEOUT', 28 | 409: 'CONFLICT', 29 | 499: 'CLIENT_CLOSED_REQUEST', 30 | 412: 'PRECONDITION_FAILED', 31 | 413: 'PAYLOAD_TOO_LARGE', 32 | 405: 'METHOD_NOT_SUPPORTED', 33 | 429: 'TOO_MANY_REQUESTS', 34 | 422: 'UNPROCESSABLE_CONTENT', 35 | 501: 'NOT_IMPLEMENTED', 36 | }; 37 | 38 | export const TRPC_ERROR_CODE_MESSAGE: Record = { 39 | PARSE_ERROR: 'Parse error', 40 | BAD_REQUEST: 'Bad request', 41 | NOT_FOUND: 'Not found', 42 | INTERNAL_SERVER_ERROR: 'Internal server error', 43 | UNAUTHORIZED: 'Unauthorized', 44 | FORBIDDEN: 'Forbidden', 45 | TIMEOUT: 'Timeout', 46 | CONFLICT: 'Conflict', 47 | CLIENT_CLOSED_REQUEST: 'Client closed request', 48 | PRECONDITION_FAILED: 'Precondition failed', 49 | PAYLOAD_TOO_LARGE: 'Payload too large', 50 | METHOD_NOT_SUPPORTED: 'Method not supported', 51 | TOO_MANY_REQUESTS: 'Too many requests', 52 | UNPROCESSABLE_CONTENT: 'Unprocessable content', 53 | NOT_IMPLEMENTED: 'Not implemented', 54 | }; 55 | 56 | export function getErrorFromUnknown(cause: unknown): TRPCError { 57 | if (cause instanceof Error && cause.name === 'TRPCError') { 58 | return cause as TRPCError; 59 | } 60 | 61 | let errorCause: Error | undefined = undefined; 62 | let stack: string | undefined = undefined; 63 | 64 | if (cause instanceof Error) { 65 | errorCause = cause; 66 | stack = cause.stack; 67 | } 68 | 69 | const error = new TRPCError({ 70 | message: 'Internal server error', 71 | code: 'INTERNAL_SERVER_ERROR', 72 | cause: errorCause, 73 | }); 74 | 75 | if (stack) { 76 | error.stack = stack; 77 | } 78 | 79 | return error; 80 | } 81 | -------------------------------------------------------------------------------- /src/adapters/node-http/input.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { NodeHTTPRequest } from '@trpc/server/dist/adapters/node-http'; 3 | import parse from 'co-body'; 4 | 5 | export const getQuery = (req: NodeHTTPRequest, url: URL): Record => { 6 | const query: Record = {}; 7 | 8 | if (!req.query) { 9 | const parsedQs: Record = {}; 10 | url.searchParams.forEach((value, key) => { 11 | if (!parsedQs[key]) { 12 | parsedQs[key] = []; 13 | } 14 | parsedQs[key]!.push(value); 15 | }); 16 | req.query = parsedQs; 17 | } 18 | 19 | // normalize first value in array 20 | Object.keys(req.query).forEach((key) => { 21 | const value = req.query![key]; 22 | if (value) { 23 | if (typeof value === 'string') { 24 | query[key] = value; 25 | } else if (Array.isArray(value)) { 26 | if (typeof value[0] === 'string') { 27 | query[key] = value[0]; 28 | } 29 | } 30 | } 31 | }); 32 | 33 | return query; 34 | }; 35 | 36 | const BODY_100_KB = 100000; 37 | export const getBody = async (req: NodeHTTPRequest, maxBodySize = BODY_100_KB): Promise => { 38 | if ('body' in req) { 39 | return req.body; 40 | } 41 | 42 | req.body = undefined; 43 | 44 | const contentType = req.headers['content-type']; 45 | if (contentType === 'application/json' || contentType === 'application/x-www-form-urlencoded') { 46 | try { 47 | const { raw, parsed } = await parse(req, { 48 | limit: maxBodySize, 49 | strict: false, 50 | returnRawBody: true, 51 | }); 52 | req.body = raw ? parsed : undefined; 53 | } catch (cause) { 54 | if (cause instanceof Error && cause.name === 'PayloadTooLargeError') { 55 | throw new TRPCError({ 56 | message: 'Request body too large', 57 | code: 'PAYLOAD_TOO_LARGE', 58 | cause: cause, 59 | }); 60 | } 61 | 62 | let errorCause: Error | undefined = undefined; 63 | if (cause instanceof Error) { 64 | errorCause = cause; 65 | } 66 | 67 | throw new TRPCError({ 68 | message: 'Failed to parse request body', 69 | code: 'PARSE_ERROR', 70 | cause: errorCause, 71 | }); 72 | } 73 | } 74 | 75 | return req.body; 76 | }; 77 | -------------------------------------------------------------------------------- /src/adapters/node-http/procedures.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMethod, OpenApiProcedure, OpenApiRouter } from '../../types'; 2 | import { getPathRegExp, normalizePath } from '../../utils/path'; 3 | import { forEachOpenApiProcedure } from '../../utils/procedure'; 4 | 5 | export const createProcedureCache = (router: OpenApiRouter) => { 6 | const procedureCache = new Map< 7 | OpenApiMethod, 8 | Map< 9 | RegExp, 10 | { 11 | type: 'query' | 'mutation'; 12 | path: string; 13 | procedure: OpenApiProcedure; 14 | } 15 | > 16 | >(); 17 | 18 | const { queries, mutations } = router._def; 19 | 20 | forEachOpenApiProcedure(queries, ({ path: queryPath, procedure, openapi }) => { 21 | const { method } = openapi; 22 | if (!procedureCache.has(method)) { 23 | procedureCache.set(method, new Map()); 24 | } 25 | const path = normalizePath(openapi.path); 26 | const pathRegExp = getPathRegExp(path); 27 | procedureCache.get(method)!.set(pathRegExp, { 28 | type: 'query', 29 | path: queryPath, 30 | procedure, 31 | }); 32 | }); 33 | 34 | forEachOpenApiProcedure(mutations, ({ path: mutationPath, procedure, openapi }) => { 35 | const { method } = openapi; 36 | if (!procedureCache.has(method)) { 37 | procedureCache.set(method, new Map()); 38 | } 39 | const path = normalizePath(openapi.path); 40 | const pathRegExp = getPathRegExp(path); 41 | procedureCache.get(method)!.set(pathRegExp, { 42 | type: 'mutation', 43 | path: mutationPath, 44 | procedure, 45 | }); 46 | }); 47 | 48 | return (method: OpenApiMethod, path: string) => { 49 | const procedureMethodCache = procedureCache.get(method); 50 | if (!procedureMethodCache) { 51 | return undefined; 52 | } 53 | 54 | const procedureRegExp = Array.from(procedureMethodCache.keys()).find((re) => re.test(path)); 55 | if (!procedureRegExp) { 56 | return undefined; 57 | } 58 | 59 | const procedure = procedureMethodCache.get(procedureRegExp)!; 60 | const pathInput = procedureRegExp.exec(path)?.groups ?? {}; 61 | 62 | return { procedure, pathInput }; 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/adapters/nuxt.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import type { NodeIncomingMessage, NodeServerResponse } from 'h3'; 3 | import { defineEventHandler, getQuery } from 'h3'; 4 | import { IncomingMessage } from 'http'; 5 | 6 | import { OpenApiErrorResponse, OpenApiRouter } from '../types'; 7 | import { normalizePath } from '../utils/path'; 8 | import { 9 | CreateOpenApiNodeHttpHandlerOptions, 10 | createOpenApiNodeHttpHandler, 11 | } from './node-http/core'; 12 | 13 | export type CreateOpenApiNuxtHandlerOptions = Omit< 14 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 15 | // @ts-ignore 16 | CreateOpenApiNodeHttpHandlerOptions, 17 | 'maxBodySize' 18 | >; 19 | 20 | type NuxtRequest = IncomingMessage & { 21 | query?: ReturnType; 22 | }; 23 | 24 | export const createOpenApiNuxtHandler = ( 25 | opts: CreateOpenApiNuxtHandlerOptions, 26 | ) => { 27 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 28 | 29 | return defineEventHandler(async (event) => { 30 | let pathname: string | null = null; 31 | 32 | const params = event.context.params; 33 | if (params && params?.trpc) { 34 | if (!params.trpc.includes('/')) { 35 | pathname = params.trpc; 36 | } else { 37 | pathname = params.trpc; 38 | } 39 | } 40 | 41 | if (pathname === null) { 42 | const error = new TRPCError({ 43 | message: 'Query "trpc" not found - is the `trpc-openapi` file named `[...trpc].ts`?', 44 | code: 'INTERNAL_SERVER_ERROR', 45 | }); 46 | 47 | opts.onError?.({ 48 | error, 49 | type: 'unknown', 50 | path: undefined, 51 | input: undefined, 52 | ctx: undefined, 53 | req: event.node.req, 54 | }); 55 | 56 | event.node.res.statusCode = 500; 57 | event.node.res.setHeader('Content-Type', 'application/json'); 58 | const body: OpenApiErrorResponse = { 59 | message: error.message, 60 | code: error.code, 61 | }; 62 | event.node.res.end(JSON.stringify(body)); 63 | 64 | return; 65 | } 66 | 67 | (event.node.req as NuxtRequest).query = getQuery(event); 68 | event.node.req.url = normalizePath(pathname); 69 | await openApiHttpHandler(event.node.req, event.node.res); 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /src/adapters/standalone.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http'; 2 | 3 | import { OpenApiRouter } from '../types'; 4 | import { 5 | CreateOpenApiNodeHttpHandlerOptions, 6 | createOpenApiNodeHttpHandler, 7 | } from './node-http/core'; 8 | 9 | export type CreateOpenApiHttpHandlerOptions = 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 11 | // @ts-ignore 12 | CreateOpenApiNodeHttpHandlerOptions; 13 | 14 | export const createOpenApiHttpHandler = ( 15 | opts: CreateOpenApiHttpHandlerOptions, 16 | ) => { 17 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 18 | return async (req: IncomingMessage, res: ServerResponse) => { 19 | await openApiHttpHandler(req, res); 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/generator/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIObject, SecuritySchemeObject } from 'openapi3-ts/dist/oas31'; 2 | import { ZodOpenApiObject, ZodOpenApiPathsObject, createDocument } from 'zod-openapi'; 3 | 4 | import { OpenApiRouter } from '../types'; 5 | import { getOpenApiPathsObject, mergePaths } from './paths'; 6 | 7 | export type GenerateOpenApiDocumentOptions = { 8 | title: string; 9 | description?: string; 10 | version: string; 11 | openApiVersion?: ZodOpenApiObject['openapi']; 12 | baseUrl: string; 13 | docsUrl?: string; 14 | tags?: string[]; 15 | securitySchemes?: Record; 16 | paths?: ZodOpenApiPathsObject; 17 | }; 18 | 19 | export const generateOpenApiDocument = ( 20 | appRouter: OpenApiRouter, 21 | opts: GenerateOpenApiDocumentOptions, 22 | ): OpenAPIObject => { 23 | const securitySchemes = opts.securitySchemes || { 24 | Authorization: { 25 | type: 'http', 26 | scheme: 'bearer', 27 | }, 28 | }; 29 | return createDocument({ 30 | openapi: opts.openApiVersion ?? '3.0.3', 31 | info: { 32 | title: opts.title, 33 | description: opts.description, 34 | version: opts.version, 35 | }, 36 | servers: [ 37 | { 38 | url: opts.baseUrl, 39 | }, 40 | ], 41 | paths: mergePaths(getOpenApiPathsObject(appRouter, Object.keys(securitySchemes)), opts.paths), 42 | components: { 43 | securitySchemes, 44 | }, 45 | tags: opts.tags?.map((tag) => ({ name: tag })), 46 | externalDocs: opts.docsUrl ? { url: opts.docsUrl } : undefined, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/generator/paths.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import cloneDeep from 'lodash.clonedeep'; 3 | import { z } from 'zod'; 4 | import { 5 | ZodOpenApiParameters, 6 | ZodOpenApiPathsObject, 7 | ZodOpenApiRequestBodyObject, 8 | extendZodWithOpenApi, 9 | } from 'zod-openapi'; 10 | 11 | import { OpenApiProcedureRecord, OpenApiRouter } from '../types'; 12 | import { acceptsRequestBody } from '../utils/method'; 13 | import { getPathParameters, normalizePath } from '../utils/path'; 14 | import { forEachOpenApiProcedure, getInputOutputParsers } from '../utils/procedure'; 15 | import { 16 | instanceofZodType, 17 | instanceofZodTypeLikeVoid, 18 | instanceofZodTypeObject, 19 | unwrapZodType, 20 | } from '../utils/zod'; 21 | import { getParameterObjects, getRequestBodyObject, getResponsesObject, hasInputs } from './schema'; 22 | 23 | extendZodWithOpenApi(z); 24 | 25 | export enum HttpMethods { 26 | GET = 'get', 27 | POST = 'post', 28 | PATCH = 'patch', 29 | PUT = 'put', 30 | DELETE = 'delete', 31 | } 32 | 33 | export const getOpenApiPathsObject = ( 34 | appRouter: OpenApiRouter, 35 | securitySchemeNames: string[], 36 | ): ZodOpenApiPathsObject => { 37 | const pathsObject: ZodOpenApiPathsObject = {}; 38 | const procedures = cloneDeep(appRouter._def.procedures as OpenApiProcedureRecord); 39 | 40 | forEachOpenApiProcedure(procedures, ({ path: procedurePath, type, procedure, openapi }) => { 41 | const procedureName = `${type}.${procedurePath}`; 42 | 43 | try { 44 | if (type === 'subscription') { 45 | throw new TRPCError({ 46 | message: 'Subscriptions are not supported by OpenAPI v3', 47 | code: 'INTERNAL_SERVER_ERROR', 48 | }); 49 | } 50 | 51 | const { 52 | method, 53 | protect, 54 | summary, 55 | description, 56 | tags, 57 | requestHeaders, 58 | responseHeaders, 59 | successDescription, 60 | errorResponses, 61 | } = openapi; 62 | 63 | const path = normalizePath(openapi.path); 64 | const pathParameters = getPathParameters(path); 65 | 66 | const httpMethod = HttpMethods[method]; 67 | if (!httpMethod) { 68 | throw new TRPCError({ 69 | message: 'Method must be GET, POST, PATCH, PUT or DELETE', 70 | code: 'INTERNAL_SERVER_ERROR', 71 | }); 72 | } 73 | 74 | if (pathsObject[path]?.[httpMethod]) { 75 | throw new TRPCError({ 76 | message: `Duplicate procedure defined for route ${method} ${path}`, 77 | code: 'INTERNAL_SERVER_ERROR', 78 | }); 79 | } 80 | 81 | const contentTypes = openapi.contentTypes || ['application/json']; 82 | if (contentTypes.length === 0) { 83 | throw new TRPCError({ 84 | message: 'At least one content type must be specified', 85 | code: 'INTERNAL_SERVER_ERROR', 86 | }); 87 | } 88 | 89 | const { inputParser, outputParser } = getInputOutputParsers(procedure); 90 | 91 | if (!instanceofZodType(inputParser)) { 92 | throw new TRPCError({ 93 | message: 'Input parser expects a Zod validator', 94 | code: 'INTERNAL_SERVER_ERROR', 95 | }); 96 | } 97 | if (!instanceofZodType(outputParser)) { 98 | throw new TRPCError({ 99 | message: 'Output parser expects a Zod validator', 100 | code: 'INTERNAL_SERVER_ERROR', 101 | }); 102 | } 103 | const isInputRequired = !inputParser.isOptional(); 104 | const o = inputParser?._def?.openapi; 105 | const inputSchema = unwrapZodType(inputParser, true).openapi({ 106 | ...(o?.title ? { title: o?.title } : {}), 107 | ...(o?.description ? { description: o?.description } : {}), 108 | }); 109 | 110 | const requestData: { 111 | requestBody?: ZodOpenApiRequestBodyObject; 112 | requestParams?: ZodOpenApiParameters; 113 | } = {}; 114 | if (!(pathParameters.length === 0 && instanceofZodTypeLikeVoid(inputSchema))) { 115 | if (!instanceofZodTypeObject(inputSchema)) { 116 | throw new TRPCError({ 117 | message: 'Input parser must be a ZodObject', 118 | code: 'INTERNAL_SERVER_ERROR', 119 | }); 120 | } 121 | 122 | if (acceptsRequestBody(method)) { 123 | requestData.requestBody = getRequestBodyObject( 124 | inputSchema, 125 | isInputRequired, 126 | pathParameters, 127 | contentTypes, 128 | ); 129 | requestData.requestParams = 130 | getParameterObjects( 131 | inputSchema, 132 | isInputRequired, 133 | pathParameters, 134 | requestHeaders, 135 | 'path', 136 | ) || {}; 137 | } else { 138 | requestData.requestParams = 139 | getParameterObjects( 140 | inputSchema, 141 | isInputRequired, 142 | pathParameters, 143 | requestHeaders, 144 | 'all', 145 | ) || {}; 146 | } 147 | } 148 | 149 | const responses = getResponsesObject( 150 | outputParser, 151 | httpMethod, 152 | responseHeaders, 153 | protect ?? false, 154 | hasInputs(inputParser), 155 | successDescription, 156 | errorResponses, 157 | ); 158 | 159 | const security = protect ? securitySchemeNames.map((name) => ({ [name]: [] })) : undefined; 160 | 161 | pathsObject[path] = { 162 | ...pathsObject[path], 163 | [httpMethod]: { 164 | operationId: procedurePath.replace(/\./g, '-'), 165 | summary, 166 | description, 167 | tags, 168 | security, 169 | ...requestData, 170 | responses, 171 | ...(openapi.deprecated ? { deprecated: openapi.deprecated } : {}), 172 | }, 173 | }; 174 | } catch (error: any) { 175 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 176 | error.message = `[${procedureName}] - ${error.message}`; 177 | throw error; 178 | } 179 | }); 180 | 181 | return pathsObject; 182 | }; 183 | 184 | export const mergePaths = (x?: ZodOpenApiPathsObject, y?: ZodOpenApiPathsObject) => { 185 | if (x === undefined) return y; 186 | if (y === undefined) return x; 187 | const obj: ZodOpenApiPathsObject = x; 188 | for (const [k, v] of Object.entries(y)) { 189 | if (k in obj) obj[k] = { ...obj[k], ...v }; 190 | else obj[k] = v; 191 | } 192 | return obj; 193 | }; 194 | -------------------------------------------------------------------------------- /src/generator/schema.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { AnyZodObject, ZodTypeAny, z } from 'zod'; 3 | import { 4 | ZodOpenApiContentObject, 5 | ZodOpenApiParameters, 6 | ZodOpenApiRequestBodyObject, 7 | ZodOpenApiResponseObject, 8 | ZodOpenApiResponsesObject, 9 | extendZodWithOpenApi, 10 | } from 'zod-openapi'; 11 | 12 | import { HTTP_STATUS_TRPC_ERROR_CODE, TRPC_ERROR_CODE_MESSAGE } from '../adapters/node-http/errors'; 13 | import { OpenApiContentType } from '../types'; 14 | import { 15 | instanceofZodType, 16 | instanceofZodTypeCoercible, 17 | instanceofZodTypeKind, 18 | instanceofZodTypeLikeString, 19 | instanceofZodTypeLikeVoid, 20 | instanceofZodTypeOptional, 21 | unwrapZodType, 22 | zodSupportsCoerce, 23 | } from '../utils/zod'; 24 | import { HttpMethods } from './paths'; 25 | 26 | extendZodWithOpenApi(z); 27 | 28 | export const getParameterObjects = ( 29 | schema: z.ZodObject, 30 | required: boolean, 31 | pathParameters: string[], 32 | headersSchema: AnyZodObject | undefined, 33 | inType: 'all' | 'path' | 'query', 34 | ): ZodOpenApiParameters | undefined => { 35 | const shape = schema.shape; 36 | const shapeKeys = Object.keys(shape); 37 | 38 | for (const pathParameter of pathParameters) { 39 | if (!shapeKeys.includes(pathParameter)) { 40 | throw new TRPCError({ 41 | message: `Input parser expects key from path: "${pathParameter}"`, 42 | code: 'INTERNAL_SERVER_ERROR', 43 | }); 44 | } 45 | } 46 | 47 | const { path, query } = shapeKeys 48 | .filter((shapeKey) => { 49 | const isPathParameter = pathParameters.includes(shapeKey); 50 | if (inType === 'path') { 51 | return isPathParameter; 52 | } else if (inType === 'query') { 53 | return !isPathParameter; 54 | } 55 | return true; 56 | }) 57 | .map((shapeKey) => { 58 | let shapeSchema = shape[shapeKey]!; 59 | const isShapeRequired = !shapeSchema.isOptional(); 60 | const isPathParameter = pathParameters.includes(shapeKey); 61 | 62 | if (!instanceofZodTypeLikeString(shapeSchema)) { 63 | if (zodSupportsCoerce) { 64 | if (!instanceofZodTypeCoercible(shapeSchema)) { 65 | throw new TRPCError({ 66 | message: `Input parser key: "${shapeKey}" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate`, 67 | code: 'INTERNAL_SERVER_ERROR', 68 | }); 69 | } 70 | } else { 71 | throw new TRPCError({ 72 | message: `Input parser key: "${shapeKey}" must be ZodString`, 73 | code: 'INTERNAL_SERVER_ERROR', 74 | }); 75 | } 76 | } 77 | 78 | if (instanceofZodTypeOptional(shapeSchema)) { 79 | if (isPathParameter) { 80 | throw new TRPCError({ 81 | message: `Path parameter: "${shapeKey}" must not be optional`, 82 | code: 'INTERNAL_SERVER_ERROR', 83 | }); 84 | } 85 | shapeSchema = shapeSchema.unwrap(); 86 | } 87 | 88 | return { 89 | name: shapeKey, 90 | paramType: isPathParameter ? 'path' : 'query', 91 | required: isPathParameter || (required && isShapeRequired), 92 | schema: shapeSchema, 93 | }; 94 | }) 95 | .reduce( 96 | ({ path, query }, { name, paramType, schema, required }) => 97 | paramType === 'path' 98 | ? { path: { ...path, [name]: required ? schema : schema.optional() }, query } 99 | : { path, query: { ...query, [name]: required ? schema : schema.optional() } }, 100 | { path: {} as Record, query: {} as Record }, 101 | ); 102 | 103 | return { header: headersSchema, path: z.object(path), query: z.object(query) }; 104 | }; 105 | 106 | export const getRequestBodyObject = ( 107 | schema: z.ZodObject, 108 | required: boolean, 109 | pathParameters: string[], 110 | contentTypes: OpenApiContentType[], 111 | ): ZodOpenApiRequestBodyObject | undefined => { 112 | // remove path parameters 113 | const mask: Record = {}; 114 | pathParameters.forEach((pathParameter) => { 115 | mask[pathParameter] = true; 116 | }); 117 | const o = schema._def.openapi; 118 | const dedupedSchema = schema.omit(mask).openapi({ 119 | ...(o?.title ? { title: o?.title } : {}), 120 | ...(o?.description ? { description: o?.description } : {}), 121 | }); 122 | 123 | // if all keys are path parameters 124 | if (pathParameters.length > 0 && Object.keys(dedupedSchema.shape).length === 0) { 125 | return undefined; 126 | } 127 | 128 | const content: ZodOpenApiContentObject = {}; 129 | for (const contentType of contentTypes) { 130 | content[contentType] = { 131 | schema: dedupedSchema, 132 | }; 133 | } 134 | return { 135 | required, 136 | content, 137 | }; 138 | }; 139 | 140 | export const hasInputs = (schema: unknown) => 141 | instanceofZodType(schema) && !instanceofZodTypeLikeVoid(unwrapZodType(schema, true)); 142 | 143 | const errorResponseObjectByCode: Record = {}; 144 | 145 | export const errorResponseObject = ( 146 | code = 'INTERNAL_SERVER_ERROR', 147 | message?: string, 148 | issues?: { message: string }[], 149 | ): ZodOpenApiResponseObject => { 150 | if (!errorResponseObjectByCode[code]) { 151 | errorResponseObjectByCode[code] = { 152 | description: message ?? 'An error response', 153 | content: { 154 | 'application/json': { 155 | schema: z 156 | .object({ 157 | message: z.string().openapi({ 158 | description: 'The error message', 159 | example: message ?? 'Internal server error', 160 | }), 161 | code: z.string().openapi({ 162 | description: 'The error code', 163 | example: code ?? 'INTERNAL_SERVER_ERROR', 164 | }), 165 | issues: z 166 | .array(z.object({ message: z.string() })) 167 | .optional() 168 | .openapi({ 169 | description: 'An array of issues that were responsible for the error', 170 | example: issues ?? [], 171 | }), 172 | }) 173 | .openapi({ 174 | title: 'Error', 175 | description: 'The error information', 176 | example: { 177 | code: code ?? 'INTERNAL_SERVER_ERROR', 178 | message: message ?? 'Internal server error', 179 | issues: issues ?? [], 180 | }, 181 | ref: `error.${code}`, 182 | }), 183 | }, 184 | }, 185 | }; 186 | } 187 | return errorResponseObjectByCode[code]!; 188 | }; 189 | 190 | export const errorResponseFromStatusCode = (status: number) => { 191 | const code = HTTP_STATUS_TRPC_ERROR_CODE[status]; 192 | const message = code && TRPC_ERROR_CODE_MESSAGE[code]; 193 | return errorResponseObject(code ?? 'UNKNOWN_ERROR', message ?? 'Unknown error'); 194 | }; 195 | 196 | export const errorResponseFromMessage = (status: number, message: string) => 197 | errorResponseObject(HTTP_STATUS_TRPC_ERROR_CODE[status] ?? 'UNKNOWN_ERROR', message); 198 | 199 | export const getResponsesObject = ( 200 | schema: ZodTypeAny, 201 | httpMethod: HttpMethods, 202 | headers: AnyZodObject | undefined, 203 | isProtected: boolean, 204 | hasInputs: boolean, 205 | successDescription?: string, 206 | errorResponses?: number[] | { [key: number]: string }, 207 | ): ZodOpenApiResponsesObject => ({ 208 | 200: { 209 | description: successDescription ?? 'Successful response', 210 | headers: headers, 211 | content: { 212 | 'application/json': { 213 | schema: instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodVoid) 214 | ? {} 215 | : instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodNever) || 216 | instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodUndefined) 217 | ? { not: {} } 218 | : schema, 219 | }, 220 | }, 221 | }, 222 | ...(errorResponses !== undefined 223 | ? Object.fromEntries( 224 | Array.isArray(errorResponses) 225 | ? errorResponses.map((x) => [x, errorResponseFromStatusCode(x)]) 226 | : Object.entries(errorResponses).map(([k, v]) => [ 227 | k, 228 | errorResponseFromMessage(Number(k), v), 229 | ]), 230 | ) 231 | : { 232 | ...(isProtected 233 | ? { 234 | 401: errorResponseObject('UNAUTHORIZED', 'Authorization not provided'), 235 | 403: errorResponseObject('FORBIDDEN', 'Insufficient access'), 236 | } 237 | : {}), 238 | ...(hasInputs 239 | ? { 240 | 400: errorResponseObject('BAD_REQUEST', 'Invalid input data'), 241 | ...(httpMethod !== HttpMethods.POST 242 | ? { 243 | 404: errorResponseObject('NOT_FOUND', 'Not found'), 244 | } 245 | : {}), 246 | } 247 | : {}), 248 | 500: errorResponseObject('INTERNAL_SERVER_ERROR', 'Internal server error'), 249 | }), 250 | }); 251 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateOpenApiAwsLambdaHandlerOptions, 3 | CreateOpenApiExpressMiddlewareOptions, 4 | CreateOpenApiFastifyPluginOptions, 5 | CreateOpenApiFetchHandlerOptions, 6 | CreateOpenApiHttpHandlerOptions, 7 | CreateOpenApiNextHandlerOptions, 8 | CreateOpenApiNuxtHandlerOptions, 9 | createOpenApiAwsLambdaHandler, 10 | createOpenApiExpressMiddleware, 11 | createOpenApiFetchHandler, 12 | createOpenApiHttpHandler, 13 | createOpenApiNextHandler, 14 | createOpenApiNuxtHandler, 15 | fastifyTRPCOpenApiPlugin, 16 | } from './adapters'; 17 | import { GenerateOpenApiDocumentOptions, generateOpenApiDocument } from './generator'; 18 | import { 19 | errorResponseFromMessage, 20 | errorResponseFromStatusCode, 21 | errorResponseObject, 22 | } from './generator/schema'; 23 | import { 24 | OpenApiErrorResponse, 25 | OpenApiMeta, 26 | OpenApiMethod, 27 | OpenApiResponse, 28 | OpenApiRouter, 29 | OpenApiSuccessResponse, 30 | } from './types'; 31 | import { ZodTypeLikeString, ZodTypeLikeVoid } from './utils/zod'; 32 | 33 | export { 34 | CreateOpenApiAwsLambdaHandlerOptions, 35 | CreateOpenApiExpressMiddlewareOptions, 36 | CreateOpenApiHttpHandlerOptions, 37 | CreateOpenApiNextHandlerOptions, 38 | CreateOpenApiFastifyPluginOptions, 39 | CreateOpenApiFetchHandlerOptions, 40 | CreateOpenApiNuxtHandlerOptions, 41 | createOpenApiExpressMiddleware, 42 | createOpenApiFetchHandler, 43 | createOpenApiHttpHandler, 44 | createOpenApiNextHandler, 45 | createOpenApiNuxtHandler, 46 | createOpenApiAwsLambdaHandler, 47 | fastifyTRPCOpenApiPlugin, 48 | generateOpenApiDocument, 49 | errorResponseObject, 50 | errorResponseFromStatusCode, 51 | errorResponseFromMessage, 52 | GenerateOpenApiDocumentOptions, 53 | OpenApiRouter, 54 | OpenApiMeta, 55 | OpenApiMethod, 56 | OpenApiResponse, 57 | OpenApiSuccessResponse, 58 | OpenApiErrorResponse, 59 | ZodTypeLikeString, 60 | ZodTypeLikeVoid, 61 | }; 62 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Procedure, ProcedureParams, Router } from '@trpc/server'; 2 | import type { RootConfig } from '@trpc/server/dist/core/internals/config'; 3 | import type { RouterDef } from '@trpc/server/dist/core/router'; 4 | import { TRPC_ERROR_CODE_KEY } from '@trpc/server/dist/rpc'; 5 | import { 6 | AnyZodObject, 7 | ZodBigInt, 8 | ZodDate, 9 | ZodEffects, 10 | ZodIssue, 11 | ZodNumber, 12 | ZodString, 13 | ZodTypeAny, 14 | } from 'zod'; 15 | 16 | export type OpenApiMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; 17 | 18 | type TRPCMeta = Record; 19 | 20 | export type OpenApiContentType = 21 | | 'application/json' 22 | | 'application/x-www-form-urlencoded' 23 | // eslint-disable-next-line @typescript-eslint/ban-types 24 | | (string & {}); 25 | 26 | export type OpenApiMeta = TMeta & { 27 | openapi?: { 28 | enabled?: boolean; 29 | method: OpenApiMethod; 30 | path: `/${string}`; 31 | summary?: string; 32 | description?: string; 33 | protect?: boolean; 34 | tags?: string[]; 35 | contentTypes?: OpenApiContentType[]; 36 | deprecated?: boolean; 37 | requestHeaders?: AnyZodObject; 38 | responseHeaders?: AnyZodObject; 39 | successDescription?: string; 40 | errorResponses?: number[] | { [key: number]: string }; 41 | }; 42 | }; 43 | 44 | export type OpenApiProcedure = Procedure< 45 | 'query' | 'mutation', 46 | ProcedureParams< 47 | RootConfig<{ 48 | transformer: any; 49 | errorShape: any; 50 | ctx: any; 51 | meta: OpenApiMeta; 52 | }>, 53 | any, 54 | any, 55 | any, 56 | any, 57 | any, 58 | OpenApiMeta 59 | > 60 | >; 61 | 62 | export type OpenApiProcedureRecord = Record>; 63 | 64 | export type OpenApiRouter = Router< 65 | RouterDef< 66 | RootConfig<{ 67 | transformer: any; 68 | errorShape: any; 69 | ctx: any; 70 | meta: OpenApiMeta; 71 | }>, 72 | any, 73 | any 74 | > 75 | >; 76 | 77 | export type OpenApiSuccessResponse = D; 78 | 79 | export type OpenApiErrorResponse = { 80 | message: string; 81 | code: TRPC_ERROR_CODE_KEY; 82 | issues?: ZodIssue[]; 83 | }; 84 | 85 | export type OpenApiResponse = OpenApiSuccessResponse | OpenApiErrorResponse; 86 | -------------------------------------------------------------------------------- /src/utils/method.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMethod } from '../types'; 2 | 3 | export const acceptsRequestBody = (method: OpenApiMethod) => { 4 | if (method === 'GET' || method === 'DELETE') { 5 | return false; 6 | } 7 | return true; 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | export const normalizePath = (path: string) => { 2 | return `/${path.replace(/^\/|\/$/g, '')}`; 3 | }; 4 | 5 | export const getPathParameters = (path: string) => { 6 | return Array.from(path.matchAll(/\{(.+?)\}/g)).map(([_, key]) => key!); 7 | }; 8 | 9 | export const getPathRegExp = (path: string) => { 10 | const groupedExp = path.replace(/\{(.+?)\}/g, (_, key: string) => `(?<${key}>[^/]+)`); 11 | return new RegExp(`^${groupedExp}$`, 'i'); 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/procedure.ts: -------------------------------------------------------------------------------- 1 | import { ProcedureType } from '@trpc/server'; 2 | // eslint-disable-next-line import/no-unresolved 3 | import { Parser } from '@trpc/server/dist/core/parser'; 4 | import { AnyZodObject, z } from 'zod'; 5 | 6 | import { OpenApiMeta, OpenApiProcedure, OpenApiProcedureRecord } from '../types'; 7 | 8 | const mergeInputs = (inputParsers: AnyZodObject[]): AnyZodObject => { 9 | return inputParsers.reduce((acc, inputParser) => { 10 | return acc.merge(inputParser); 11 | }, z.object({})); 12 | }; 13 | 14 | // `inputParser` & `outputParser` are private so this is a hack to access it 15 | export const getInputOutputParsers = ( 16 | procedure: OpenApiProcedure, 17 | ): { 18 | inputParser: AnyZodObject | Parser | undefined; 19 | outputParser: Parser | undefined; 20 | } => { 21 | const { inputs, output } = procedure._def; 22 | return { 23 | inputParser: inputs.length >= 2 ? mergeInputs(inputs as AnyZodObject[]) : inputs[0], 24 | outputParser: output, 25 | }; 26 | }; 27 | 28 | const getProcedureType = (procedure: OpenApiProcedure): ProcedureType => { 29 | if (procedure._def.query) return 'query'; 30 | if (procedure._def.mutation) return 'mutation'; 31 | if (procedure._def.subscription) return 'subscription'; 32 | throw new Error('Unknown procedure type'); 33 | }; 34 | 35 | export const forEachOpenApiProcedure = ( 36 | procedureRecord: OpenApiProcedureRecord, 37 | callback: (values: { 38 | path: string; 39 | type: ProcedureType; 40 | procedure: OpenApiProcedure; 41 | openapi: NonNullable; 42 | }) => void, 43 | ) => { 44 | for (const [path, procedure] of Object.entries(procedureRecord)) { 45 | const { openapi } = procedure._def.meta ?? {}; 46 | if (openapi && openapi.enabled !== false) { 47 | const type = getProcedureType(procedure); 48 | callback({ path, type, procedure, openapi }); 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import { ZodObject, ZodRawShape, ZodTypeAny, z } from 'zod'; 2 | 3 | export const instanceofZodType = (type: any): type is z.ZodTypeAny => { 4 | return !!type?._def?.typeName; 5 | }; 6 | 7 | export const instanceofZodTypeKind = ( 8 | type: z.ZodTypeAny, 9 | zodTypeKind: Z, 10 | ): type is InstanceType<(typeof z)[Z]> => { 11 | return type?._def?.typeName === zodTypeKind; 12 | }; 13 | 14 | export const instanceofZodTypeOptional = ( 15 | type: z.ZodTypeAny, 16 | ): type is z.ZodOptional => { 17 | return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional); 18 | }; 19 | 20 | export const instanceofZodTypeObject = (type: z.ZodTypeAny): type is z.ZodObject => { 21 | return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodObject); 22 | }; 23 | 24 | export type ZodTypeLikeVoid = z.ZodVoid | z.ZodUndefined | z.ZodNever; 25 | 26 | export const instanceofZodTypeLikeVoid = (type: z.ZodTypeAny): type is ZodTypeLikeVoid => { 27 | return ( 28 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodVoid) || 29 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUndefined) || 30 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNever) 31 | ); 32 | }; 33 | 34 | export const unwrapZodType = (type: z.ZodTypeAny, unwrapPreprocess: boolean): z.ZodTypeAny => { 35 | // TODO: Allow parsing array query params 36 | // if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodArray)) { 37 | // return unwrapZodType(type.element, unwrapPreprocess); 38 | // } 39 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEnum)) { 40 | return unwrapZodType(z.string(), unwrapPreprocess); 41 | } 42 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNullable)) { 43 | return unwrapZodType(type.unwrap(), unwrapPreprocess); 44 | } 45 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBranded)) { 46 | return unwrapZodType(type.unwrap(), unwrapPreprocess); 47 | } 48 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional)) { 49 | return unwrapZodType(type.unwrap(), unwrapPreprocess); 50 | } 51 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDefault)) { 52 | return unwrapZodType(type.removeDefault(), unwrapPreprocess); 53 | } 54 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLazy)) { 55 | return unwrapZodType(type._def.getter(), unwrapPreprocess); 56 | } 57 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { 58 | if (type._def.effect.type === 'refinement') { 59 | return unwrapZodType(type._def.schema, unwrapPreprocess); 60 | } 61 | if (type._def.effect.type === 'transform') { 62 | return unwrapZodType(type._def.schema, unwrapPreprocess); 63 | } 64 | if (unwrapPreprocess && type._def.effect.type === 'preprocess') { 65 | return unwrapZodType(type._def.schema, unwrapPreprocess); 66 | } 67 | } 68 | return type; 69 | }; 70 | 71 | type NativeEnumType = { 72 | [k: string]: string | number; 73 | [nu: number]: string; 74 | }; 75 | 76 | export type ZodTypeLikeString = 77 | | z.ZodString 78 | | z.ZodOptional 79 | | z.ZodDefault 80 | | z.ZodEffects 81 | | z.ZodUnion<[ZodTypeLikeString, ...ZodTypeLikeString[]]> 82 | | z.ZodIntersection 83 | | z.ZodLazy 84 | | z.ZodLiteral 85 | | z.ZodEnum<[string, ...string[]]> 86 | | z.ZodNativeEnum; 87 | 88 | export const instanceofZodTypeLikeString = (_type: z.ZodTypeAny): _type is ZodTypeLikeString => { 89 | const type = unwrapZodType(_type, false); 90 | 91 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { 92 | if (type._def.effect.type === 'preprocess') { 93 | return true; 94 | } 95 | } 96 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUnion)) { 97 | return !type._def.options.some((option) => !instanceofZodTypeLikeString(option)); 98 | } 99 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodIntersection)) { 100 | return ( 101 | instanceofZodTypeLikeString(type._def.left) && instanceofZodTypeLikeString(type._def.right) 102 | ); 103 | } 104 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLiteral)) { 105 | return typeof type._def.value === 'string'; 106 | } 107 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEnum)) { 108 | return true; 109 | } 110 | if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNativeEnum)) { 111 | return !Object.values(type._def.values).some((value) => typeof value === 'number'); 112 | } 113 | return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodString); 114 | }; 115 | 116 | export const zodSupportsCoerce = 'coerce' in z; 117 | 118 | export type ZodTypeCoercible = z.ZodNumber | z.ZodBoolean | z.ZodBigInt | z.ZodDate; 119 | 120 | export const instanceofZodTypeCoercible = (_type: z.ZodTypeAny): _type is ZodTypeCoercible => { 121 | const type = unwrapZodType(_type, false); 122 | return ( 123 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNumber) || 124 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBoolean) || 125 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBigInt) || 126 | instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDate) 127 | ); 128 | }; 129 | 130 | export const coerceSchema = (schema: ZodObject) => { 131 | Object.values(schema.shape).forEach((shapeSchema) => { 132 | const unwrappedShapeSchema = unwrapZodType(shapeSchema, false); 133 | if (instanceofZodTypeCoercible(unwrappedShapeSchema)) unwrappedShapeSchema._def.coerce = true; 134 | else if (instanceofZodTypeObject(unwrappedShapeSchema)) coerceSchema(unwrappedShapeSchema); 135 | }); 136 | }; 137 | -------------------------------------------------------------------------------- /test/adapters/aws-lambda.utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIGatewayProxyEvent, 3 | APIGatewayProxyEventPathParameters, 4 | APIGatewayProxyEventV2, 5 | Context, 6 | } from 'aws-lambda'; 7 | 8 | export const mockAPIGatewayProxyEventV1 = ({ 9 | body, 10 | headers, 11 | path, 12 | queryStringParameters, 13 | method, 14 | resource, 15 | pathParameters, 16 | }: { 17 | body: string; 18 | headers: { [key: string]: string }; 19 | queryStringParameters: Record; 20 | path: string; 21 | method: string; 22 | resource: string; 23 | pathParameters?: APIGatewayProxyEventPathParameters; 24 | }): APIGatewayProxyEvent => { 25 | return { 26 | body, 27 | headers, 28 | multiValueHeaders: {}, 29 | path: `/${path}`, 30 | httpMethod: method, 31 | isBase64Encoded: false, 32 | queryStringParameters, 33 | multiValueQueryStringParameters: null, 34 | resource, 35 | pathParameters: pathParameters || null, 36 | stageVariables: {}, 37 | requestContext: { 38 | accountId: 'mock', 39 | apiId: 'mock', 40 | path: 'mock', 41 | protocol: 'mock', 42 | httpMethod: method, 43 | stage: 'mock', 44 | requestId: 'mock', 45 | requestTimeEpoch: 123, 46 | resourceId: 'mock', 47 | resourcePath: 'mock', 48 | identity: { 49 | accessKey: null, 50 | accountId: null, 51 | apiKey: null, 52 | apiKeyId: null, 53 | caller: null, 54 | clientCert: null, 55 | cognitoAuthenticationProvider: null, 56 | cognitoAuthenticationType: null, 57 | cognitoIdentityId: null, 58 | cognitoIdentityPoolId: null, 59 | principalOrgId: null, 60 | sourceIp: 'mock', 61 | user: null, 62 | userAgent: null, 63 | userArn: null, 64 | }, 65 | authorizer: {}, 66 | }, 67 | }; 68 | }; 69 | 70 | export const mockAPIGatewayProxyEventV2 = ({ 71 | body, 72 | headers, 73 | path, 74 | queryStringParameters, 75 | method, 76 | routeKey, 77 | pathParameters, 78 | }: { 79 | body: string; 80 | headers: { [key: string]: string }; 81 | queryStringParameters: Record; 82 | path: string; 83 | method: string; 84 | routeKey: string; 85 | pathParameters?: { [key: string]: string }; 86 | }): APIGatewayProxyEventV2 => { 87 | return { 88 | version: '2.0', 89 | routeKey, 90 | rawQueryString: path, 91 | body, 92 | headers, 93 | rawPath: `/${path}`, 94 | pathParameters, 95 | isBase64Encoded: false, 96 | queryStringParameters: queryStringParameters, 97 | stageVariables: {}, 98 | requestContext: { 99 | accountId: 'mock', 100 | apiId: 'mock', 101 | stage: 'mock', 102 | requestId: 'mock', 103 | domainName: 'mock', 104 | domainPrefix: 'mock', 105 | http: { 106 | method: method, 107 | path: 'mock', 108 | protocol: 'mock', 109 | sourceIp: 'mock', 110 | userAgent: 'mock', 111 | }, 112 | routeKey, 113 | time: 'mock', 114 | timeEpoch: 0, 115 | }, 116 | }; 117 | }; 118 | 119 | export const mockAPIGatewayContext = (): Context => { 120 | return { 121 | functionName: 'mock', 122 | callbackWaitsForEmptyEventLoop: true, 123 | functionVersion: 'mock', 124 | invokedFunctionArn: 'mock', 125 | memoryLimitInMB: 'mock', 126 | awsRequestId: 'mock', 127 | logGroupName: 'mock', 128 | logStreamName: 'mock', 129 | getRemainingTimeInMillis: () => -1, 130 | // eslint-disable-next-line @typescript-eslint/no-empty-function 131 | done: () => {}, 132 | // eslint-disable-next-line @typescript-eslint/no-empty-function 133 | fail: () => {}, 134 | // eslint-disable-next-line @typescript-eslint/no-empty-function 135 | succeed: () => {}, 136 | }; 137 | }; 138 | -------------------------------------------------------------------------------- /test/adapters/express.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { initTRPC } from '@trpc/server'; 3 | import express from 'express'; 4 | import fetch from 'node-fetch'; 5 | import { z } from 'zod'; 6 | 7 | import { 8 | CreateOpenApiExpressMiddlewareOptions, 9 | OpenApiMeta, 10 | OpenApiRouter, 11 | createOpenApiExpressMiddleware, 12 | } from '../../src'; 13 | 14 | const createContextMock = jest.fn(); 15 | const responseMetaMock = jest.fn(); 16 | const onErrorMock = jest.fn(); 17 | 18 | const clearMocks = () => { 19 | createContextMock.mockClear(); 20 | responseMetaMock.mockClear(); 21 | onErrorMock.mockClear(); 22 | }; 23 | 24 | const createExpressServerWithRouter = ( 25 | handlerOpts: CreateOpenApiExpressMiddlewareOptions, 26 | serverOpts?: { basePath?: `/${string}` }, 27 | ) => { 28 | const openApiExpressMiddleware = createOpenApiExpressMiddleware({ 29 | router: handlerOpts.router, 30 | createContext: handlerOpts.createContext ?? createContextMock, 31 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 32 | onError: handlerOpts.onError ?? onErrorMock, 33 | maxBodySize: handlerOpts.maxBodySize, 34 | } as any); 35 | 36 | const app = express(); 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 39 | app.use(serverOpts?.basePath ?? '/', openApiExpressMiddleware); 40 | 41 | const server = app.listen(0); 42 | const port = (server.address() as any).port as number; 43 | const url = `http://localhost:${port}`; 44 | 45 | return { 46 | url, 47 | close: () => server.close(), 48 | }; 49 | }; 50 | 51 | const t = initTRPC.meta().context().create(); 52 | 53 | describe('express adapter', () => { 54 | afterEach(() => { 55 | clearMocks(); 56 | }); 57 | 58 | test('with valid routes', async () => { 59 | const appRouter = t.router({ 60 | sayHelloQuery: t.procedure 61 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 62 | .input(z.object({ name: z.string() })) 63 | .output(z.object({ greeting: z.string() })) 64 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 65 | sayHelloMutation: t.procedure 66 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 67 | .input(z.object({ name: z.string() })) 68 | .output(z.object({ greeting: z.string() })) 69 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 70 | sayHelloSlash: t.procedure 71 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 72 | .input(z.object({ name: z.string() })) 73 | .output(z.object({ greeting: z.string() })) 74 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 75 | }); 76 | 77 | const { url, close } = createExpressServerWithRouter({ 78 | router: appRouter, 79 | }); 80 | 81 | { 82 | const res = await fetch(`${url}/say-hello?name=Lily`, { method: 'GET' }); 83 | const body = await res.json(); 84 | 85 | expect(res.status).toBe(200); 86 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 87 | expect(createContextMock).toHaveBeenCalledTimes(1); 88 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 89 | expect(onErrorMock).toHaveBeenCalledTimes(0); 90 | 91 | clearMocks(); 92 | } 93 | { 94 | const res = await fetch(`${url}/say-hello`, { 95 | method: 'POST', 96 | headers: { 'Content-Type': 'application/json' }, 97 | body: JSON.stringify({ name: 'Lily' }), 98 | }); 99 | const body = await res.json(); 100 | 101 | expect(res.status).toBe(200); 102 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 103 | expect(createContextMock).toHaveBeenCalledTimes(1); 104 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 105 | expect(onErrorMock).toHaveBeenCalledTimes(0); 106 | 107 | clearMocks(); 108 | } 109 | { 110 | const res = await fetch(`${url}/say/hello?name=Lily`, { method: 'GET' }); 111 | const body = await res.json(); 112 | 113 | expect(res.status).toBe(200); 114 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 115 | expect(createContextMock).toHaveBeenCalledTimes(1); 116 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 117 | expect(onErrorMock).toHaveBeenCalledTimes(0); 118 | } 119 | 120 | close(); 121 | }); 122 | 123 | test('with basePath', async () => { 124 | const appRouter = t.router({ 125 | echo: t.procedure 126 | .meta({ openapi: { method: 'GET', path: '/echo' } }) 127 | .input(z.object({ payload: z.string() })) 128 | .output(z.object({ payload: z.string() })) 129 | .query(({ input }) => ({ payload: input.payload })), 130 | }); 131 | 132 | const { url, close } = createExpressServerWithRouter( 133 | { router: appRouter }, 134 | { basePath: '/open-api' }, 135 | ); 136 | 137 | const res = await fetch(`${url}/open-api/echo?payload=lilyrose2798`, { method: 'GET' }); 138 | const body = await res.json(); 139 | 140 | expect(res.status).toBe(200); 141 | expect(body).toEqual({ 142 | payload: 'lilyrose2798', 143 | }); 144 | expect(createContextMock).toHaveBeenCalledTimes(1); 145 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 146 | expect(onErrorMock).toHaveBeenCalledTimes(0); 147 | 148 | close(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/adapters/fastify.test.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import fastify from 'fastify'; 3 | import fetch from 'node-fetch'; 4 | import { z } from 'zod'; 5 | 6 | import { 7 | CreateOpenApiFastifyPluginOptions, 8 | OpenApiMeta, 9 | OpenApiRouter, 10 | fastifyTRPCOpenApiPlugin, 11 | } from '../../src'; 12 | 13 | const createContextMock = jest.fn(); 14 | const responseMetaMock = jest.fn(); 15 | const onErrorMock = jest.fn(); 16 | 17 | const clearMocks = () => { 18 | createContextMock.mockClear(); 19 | responseMetaMock.mockClear(); 20 | onErrorMock.mockClear(); 21 | }; 22 | 23 | const createFastifyServerWithRouter = async ( 24 | handler: CreateOpenApiFastifyPluginOptions, 25 | opts?: { 26 | serverOpts?: { basePath?: `/${string}` }; 27 | prefix?: string; 28 | }, 29 | ) => { 30 | const server = fastify(); 31 | 32 | const openApiFastifyPluginOptions: any = { 33 | router: handler.router, 34 | createContext: handler.createContext ?? createContextMock, 35 | responseMeta: handler.responseMeta ?? responseMetaMock, 36 | onError: handler.onError ?? onErrorMock, 37 | maxBodySize: handler.maxBodySize, 38 | basePath: opts?.serverOpts?.basePath, 39 | }; 40 | 41 | await server.register( 42 | async (server) => { 43 | await server.register(fastifyTRPCOpenApiPlugin, openApiFastifyPluginOptions); 44 | }, 45 | { prefix: opts?.prefix ?? '' }, 46 | ); 47 | 48 | const port = 0; 49 | const url = await server.listen({ port }); 50 | 51 | return { 52 | url, 53 | close: () => server.close(), 54 | }; 55 | }; 56 | 57 | const t = initTRPC.meta().context().create(); 58 | 59 | describe('fastify adapter', () => { 60 | afterEach(() => { 61 | clearMocks(); 62 | }); 63 | 64 | test('with valid routes', async () => { 65 | const appRouter = t.router({ 66 | sayHelloQuery: t.procedure 67 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 68 | .input(z.object({ name: z.string() })) 69 | .output(z.object({ greeting: z.string() })) 70 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 71 | sayHelloMutation: t.procedure 72 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 73 | .input(z.object({ name: z.string() })) 74 | .output(z.object({ greeting: z.string() })) 75 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 76 | sayHelloSlash: t.procedure 77 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 78 | .input(z.object({ name: z.string() })) 79 | .output(z.object({ greeting: z.string() })) 80 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 81 | }); 82 | 83 | const { url, close } = await createFastifyServerWithRouter({ router: appRouter }); 84 | 85 | { 86 | const res = await fetch(`${url}/say-hello?name=Lily`, { method: 'GET' }); 87 | const body = await res.json(); 88 | 89 | expect(res.status).toBe(200); 90 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 91 | expect(createContextMock).toHaveBeenCalledTimes(1); 92 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 93 | expect(onErrorMock).toHaveBeenCalledTimes(0); 94 | 95 | clearMocks(); 96 | } 97 | { 98 | const res = await fetch(`${url}/say-hello`, { 99 | method: 'POST', 100 | headers: { 'Content-Type': 'application/json' }, 101 | body: JSON.stringify({ name: 'Lily' }), 102 | }); 103 | const body = await res.json(); 104 | 105 | expect(res.status).toBe(200); 106 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 107 | expect(createContextMock).toHaveBeenCalledTimes(1); 108 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 109 | expect(onErrorMock).toHaveBeenCalledTimes(0); 110 | 111 | clearMocks(); 112 | } 113 | { 114 | const res = await fetch(`${url}/say/hello?name=Lily`, { method: 'GET' }); 115 | const body = await res.json(); 116 | 117 | expect(res.status).toBe(200); 118 | expect(body).toEqual({ greeting: 'Hello Lily!' }); 119 | expect(createContextMock).toHaveBeenCalledTimes(1); 120 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 121 | expect(onErrorMock).toHaveBeenCalledTimes(0); 122 | } 123 | 124 | await close(); 125 | }); 126 | 127 | test('with basePath', async () => { 128 | const appRouter = t.router({ 129 | echo: t.procedure 130 | .meta({ openapi: { method: 'GET', path: '/echo' } }) 131 | .input(z.object({ payload: z.string() })) 132 | .output(z.object({ payload: z.string() })) 133 | .query(({ input }) => ({ payload: input.payload })), 134 | }); 135 | 136 | const { url, close } = await createFastifyServerWithRouter( 137 | { router: appRouter }, 138 | { serverOpts: { basePath: '/open-api' } }, 139 | ); 140 | 141 | const res = await fetch(`${url}/open-api/echo?payload=lilyrose2798`, { method: 'GET' }); 142 | const body = await res.json(); 143 | 144 | expect(res.status).toBe(200); 145 | expect(body).toEqual({ 146 | payload: 'lilyrose2798', 147 | }); 148 | expect(createContextMock).toHaveBeenCalledTimes(1); 149 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 150 | expect(onErrorMock).toHaveBeenCalledTimes(0); 151 | 152 | await close(); 153 | }); 154 | 155 | test('with prefix', async () => { 156 | const appRouter = t.router({ 157 | echo: t.procedure 158 | .meta({ openapi: { method: 'GET', path: '/echo' } }) 159 | .input(z.object({ payload: z.string() })) 160 | .output(z.object({ payload: z.string() })) 161 | .query(({ input }) => ({ payload: input.payload })), 162 | }); 163 | 164 | const { url, close } = await createFastifyServerWithRouter( 165 | { router: appRouter }, 166 | { 167 | prefix: '/api-prefix', 168 | }, 169 | ); 170 | 171 | const res = await fetch(`${url}/api-prefix/echo?payload=lilyrose2798`, { 172 | method: 'GET', 173 | }); 174 | const body = await res.json(); 175 | 176 | expect(res.status).toBe(200); 177 | expect(body).toEqual({ 178 | payload: 'lilyrose2798', 179 | }); 180 | expect(createContextMock).toHaveBeenCalledTimes(1); 181 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 182 | expect(onErrorMock).toHaveBeenCalledTimes(0); 183 | 184 | await close(); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /test/adapters/next.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { initTRPC } from '@trpc/server'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | import { z } from 'zod'; 5 | 6 | import { 7 | CreateOpenApiNextHandlerOptions, 8 | OpenApiMeta, 9 | OpenApiResponse, 10 | OpenApiRouter, 11 | createOpenApiNextHandler, 12 | } from '../../src'; 13 | 14 | const createContextMock = jest.fn(); 15 | const responseMetaMock = jest.fn(); 16 | const onErrorMock = jest.fn(); 17 | 18 | const clearMocks = () => { 19 | createContextMock.mockClear(); 20 | responseMetaMock.mockClear(); 21 | onErrorMock.mockClear(); 22 | }; 23 | 24 | const createOpenApiNextHandlerCaller = ( 25 | handlerOpts: CreateOpenApiNextHandlerOptions, 26 | ) => { 27 | const openApiNextHandler = createOpenApiNextHandler({ 28 | router: handlerOpts.router, 29 | createContext: handlerOpts.createContext ?? createContextMock, 30 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 31 | onError: handlerOpts.onError ?? onErrorMock, 32 | } as any); 33 | 34 | return (req: { method: string; query: Record; body?: any }) => { 35 | return new Promise<{ 36 | statusCode: number; 37 | headers: Record; 38 | body: OpenApiResponse | undefined; 39 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor */ 40 | }>(async (resolve, reject) => { 41 | const headers = new Map(); 42 | let body: any; 43 | const res: any = { 44 | statusCode: undefined, 45 | setHeader: (key: string, value: any) => headers.set(key, value), 46 | end: (data: string) => { 47 | body = JSON.parse(data); 48 | }, 49 | }; 50 | 51 | try { 52 | await openApiNextHandler( 53 | req as unknown as NextApiRequest, 54 | res as unknown as NextApiResponse, 55 | ); 56 | resolve({ 57 | statusCode: res.statusCode, 58 | headers: Object.fromEntries(headers.entries()), 59 | body, 60 | }); 61 | } catch (error) { 62 | reject(error); 63 | } 64 | }); 65 | }; 66 | }; 67 | 68 | const t = initTRPC.meta().context().create(); 69 | 70 | describe('next adapter', () => { 71 | afterEach(() => { 72 | clearMocks(); 73 | }); 74 | 75 | test('with valid routes', async () => { 76 | const appRouter = t.router({ 77 | sayHelloQuery: t.procedure 78 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 79 | .input(z.object({ name: z.string() })) 80 | .output(z.object({ greeting: z.string() })) 81 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 82 | sayHelloMutation: t.procedure 83 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 84 | .input(z.object({ name: z.string() })) 85 | .output(z.object({ greeting: z.string() })) 86 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 87 | sayHelloSlash: t.procedure 88 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 89 | .input(z.object({ name: z.string() })) 90 | .output(z.object({ greeting: z.string() })) 91 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 92 | }); 93 | 94 | const openApiNextHandlerCaller = createOpenApiNextHandlerCaller({ 95 | router: appRouter, 96 | }); 97 | 98 | { 99 | const res = await openApiNextHandlerCaller({ 100 | method: 'GET', 101 | query: { trpc: 'say-hello', name: 'Lily' }, 102 | }); 103 | 104 | expect(res.statusCode).toBe(200); 105 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 106 | expect(createContextMock).toHaveBeenCalledTimes(1); 107 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 108 | expect(onErrorMock).toHaveBeenCalledTimes(0); 109 | 110 | clearMocks(); 111 | } 112 | { 113 | const res = await openApiNextHandlerCaller({ 114 | method: 'POST', 115 | query: { trpc: 'say-hello' }, 116 | body: { name: 'Lily' }, 117 | }); 118 | 119 | expect(res.statusCode).toBe(200); 120 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 121 | expect(createContextMock).toHaveBeenCalledTimes(1); 122 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 123 | expect(onErrorMock).toHaveBeenCalledTimes(0); 124 | 125 | clearMocks(); 126 | } 127 | { 128 | const res = await openApiNextHandlerCaller({ 129 | method: 'GET', 130 | query: { trpc: ['say', 'hello'], name: 'Lily' }, 131 | }); 132 | 133 | expect(res.statusCode).toBe(200); 134 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 135 | expect(createContextMock).toHaveBeenCalledTimes(1); 136 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 137 | expect(onErrorMock).toHaveBeenCalledTimes(0); 138 | } 139 | }); 140 | 141 | test('with invalid path', async () => { 142 | const appRouter = t.router({}); 143 | 144 | const openApiNextHandlerCaller = createOpenApiNextHandlerCaller({ 145 | router: appRouter, 146 | }); 147 | 148 | const res = await openApiNextHandlerCaller({ 149 | method: 'GET', 150 | query: {}, 151 | }); 152 | 153 | expect(res.statusCode).toBe(500); 154 | expect(res.body).toEqual({ 155 | message: 'Query "trpc" not found - is the `trpc-openapi` file named `[...trpc].ts`?', 156 | code: 'INTERNAL_SERVER_ERROR', 157 | }); 158 | expect(createContextMock).toHaveBeenCalledTimes(0); 159 | expect(responseMetaMock).toHaveBeenCalledTimes(0); 160 | expect(onErrorMock).toHaveBeenCalledTimes(1); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /test/adapters/nuxt.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { initTRPC } from '@trpc/server'; 3 | import { H3Event } from 'h3'; 4 | import httpMocks, { RequestMethod } from 'node-mocks-http'; 5 | import { z } from 'zod'; 6 | 7 | import { 8 | CreateOpenApiNuxtHandlerOptions, 9 | OpenApiMeta, 10 | OpenApiResponse, 11 | OpenApiRouter, 12 | createOpenApiNuxtHandler, 13 | } from '../../src'; 14 | 15 | const createContextMock = jest.fn(); 16 | const responseMetaMock = jest.fn(); 17 | const onErrorMock = jest.fn(); 18 | 19 | const clearMocks = () => { 20 | createContextMock.mockClear(); 21 | responseMetaMock.mockClear(); 22 | onErrorMock.mockClear(); 23 | }; 24 | 25 | const createOpenApiNuxtHandlerCaller = ( 26 | handlerOpts: CreateOpenApiNuxtHandlerOptions, 27 | ) => { 28 | const openApiNuxtHandler = createOpenApiNuxtHandler({ 29 | router: handlerOpts.router, 30 | createContext: handlerOpts.createContext ?? createContextMock, 31 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 32 | onError: handlerOpts.onError ?? onErrorMock, 33 | } as never); 34 | 35 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor */ 36 | return (req: { 37 | method: RequestMethod; 38 | params: Record; 39 | url?: string; 40 | body?: any; 41 | }) => 42 | new Promise<{ 43 | statusCode: number; 44 | headers: Record; 45 | body: OpenApiResponse | undefined; 46 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor */ 47 | }>(async (resolve, reject) => { 48 | const headers = new Map(); 49 | let body: any; 50 | 51 | const res: any = { 52 | statusCode: undefined, 53 | setHeader: (key: string, value: any) => headers.set(key, value), 54 | end: (data: string) => { 55 | body = JSON.parse(data); 56 | }, 57 | }; 58 | 59 | const mockReq = httpMocks.createRequest({ 60 | body: req.body, 61 | method: req.method, 62 | url: req.url, 63 | }); 64 | const mockRes = httpMocks.createResponse({ 65 | req: mockReq, 66 | }); 67 | mockRes.setHeader = res.setHeader; 68 | mockRes.end = res.end; 69 | const event = new H3Event(mockReq, mockRes); 70 | event.context.params = req.params; 71 | try { 72 | await openApiNuxtHandler(event); 73 | resolve({ 74 | statusCode: mockRes.statusCode, 75 | headers, 76 | body, 77 | }); 78 | } catch (error) { 79 | reject(error); 80 | } 81 | }); 82 | }; 83 | 84 | const t = initTRPC.meta().context().create(); 85 | 86 | describe('nuxt adapter', () => { 87 | afterEach(() => { 88 | clearMocks(); 89 | }); 90 | 91 | test('with valid routes', async () => { 92 | const appRouter = t.router({ 93 | sayHelloQuery: t.procedure 94 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 95 | .input(z.object({ name: z.string() })) 96 | .output(z.object({ greeting: z.string() })) 97 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 98 | sayHelloMutation: t.procedure 99 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 100 | .input(z.object({ name: z.string() })) 101 | .output(z.object({ greeting: z.string() })) 102 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 103 | sayHelloSlash: t.procedure 104 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 105 | .input(z.object({ name: z.string() })) 106 | .output(z.object({ greeting: z.string() })) 107 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 108 | }); 109 | 110 | const openApiNuxtHandlerCaller = createOpenApiNuxtHandlerCaller({ 111 | router: appRouter, 112 | }); 113 | 114 | { 115 | const res = await openApiNuxtHandlerCaller({ 116 | method: 'GET', 117 | params: { trpc: 'say-hello' }, 118 | url: '/api/say-hello?name=Lily', 119 | }); 120 | 121 | expect(res.statusCode).toBe(200); 122 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 123 | expect(createContextMock).toHaveBeenCalledTimes(1); 124 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 125 | expect(onErrorMock).toHaveBeenCalledTimes(0); 126 | 127 | clearMocks(); 128 | } 129 | { 130 | const res = await openApiNuxtHandlerCaller({ 131 | method: 'POST', 132 | params: { trpc: 'say-hello' }, 133 | body: { name: 'Lily' }, 134 | }); 135 | 136 | expect(res.statusCode).toBe(200); 137 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 138 | expect(createContextMock).toHaveBeenCalledTimes(1); 139 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 140 | expect(onErrorMock).toHaveBeenCalledTimes(0); 141 | 142 | clearMocks(); 143 | } 144 | { 145 | const res = await openApiNuxtHandlerCaller({ 146 | method: 'GET', 147 | params: { trpc: 'say/hello' }, 148 | url: '/api/say/hello?name=Lily', 149 | }); 150 | 151 | expect(res.statusCode).toBe(200); 152 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 153 | expect(createContextMock).toHaveBeenCalledTimes(1); 154 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 155 | expect(onErrorMock).toHaveBeenCalledTimes(0); 156 | } 157 | }); 158 | 159 | test('with invalid path', async () => { 160 | const appRouter = t.router({}); 161 | 162 | const openApiNuxtHandlerCaller = createOpenApiNuxtHandlerCaller({ 163 | router: appRouter, 164 | }); 165 | 166 | const res = await openApiNuxtHandlerCaller({ 167 | method: 'GET', 168 | params: {}, 169 | }); 170 | 171 | expect(res.statusCode).toBe(500); 172 | expect(res.body).toEqual({ 173 | message: 'Query "trpc" not found - is the `trpc-openapi` file named `[...trpc].ts`?', 174 | code: 'INTERNAL_SERVER_ERROR', 175 | }); 176 | expect(createContextMock).toHaveBeenCalledTimes(0); 177 | expect(responseMetaMock).toHaveBeenCalledTimes(0); 178 | expect(onErrorMock).toHaveBeenCalledTimes(1); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /tsconfig.build.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "dist/cjs" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.build.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "esnext", 6 | "outDir": "dist/esm" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*", ".eslintrc.js"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "ES2017", 5 | "module": "commonjs", 6 | "strict": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "skipLibCheck": true, 11 | "allowJs": true, 12 | "checkJs": false, 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "removeComments": false, 16 | "noUncheckedIndexedAccess": true 17 | }, 18 | "include": ["src", "test"] 19 | } 20 | --------------------------------------------------------------------------------