├── .eslintrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── 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 ├── jest.config.ts ├── package-lock.json ├── package.json ├── prettier.config.js ├── rename.js ├── src ├── adapters │ ├── express.ts │ ├── fastify.ts │ ├── fetch.ts │ ├── index.ts │ ├── koa.ts │ ├── next.ts │ ├── node-http │ │ ├── core.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── input.ts │ │ └── procedures.ts │ ├── nuxt.ts │ └── standalone.ts ├── generator │ ├── index.ts │ ├── paths.ts │ └── schema.ts ├── index.ts ├── types.ts └── utils │ ├── index.ts │ ├── method.ts │ ├── path.ts │ ├── procedure.ts │ └── zod.ts ├── test ├── adapters │ ├── express.test.ts │ ├── fastify.test.ts │ ├── fetch.test.ts │ ├── koa.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 | 'plugin:@typescript-eslint/recommended-type-checked', 8 | 'plugin:@typescript-eslint/stylistic-type-checked', 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | ecmaFeatures: { jsx: false }, 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | project: './tsconfig.eslint.json', 16 | tsconfigRootDir: __dirname, 17 | }, 18 | env: { 19 | node: true, 20 | }, 21 | rules: { 22 | '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], 23 | '@typescript-eslint/no-unsafe-argument': 'warn', 24 | '@typescript-eslint/no-unsafe-assignment': 'warn', 25 | '@typescript-eslint/no-unsafe-call': 'warn', 26 | '@typescript-eslint/no-unsafe-member-access': 'warn', 27 | '@typescript-eslint/no-unsafe-return': 'warn', 28 | '@typescript-eslint/no-explicit-any': 'warn', 29 | '@typescript-eslint/consistent-type-definitions': 'warn', 30 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 31 | }, 32 | overrides: [ 33 | { 34 | files: ['*.js', '*.jsx'], 35 | rules: {}, 36 | }, 37 | ], 38 | ignorePatterns: ['rename.js', 'dist/', 'examples/'], 39 | }; 40 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mcampa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | reviewers: 8 | - 'mcampa' 9 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ['master'] 9 | pull_request: 10 | branches: ['master'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 22.x 22 | registry-url: 'https://registry.npmjs.org' 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Check if version is publishable 29 | id: check_version 30 | run: | 31 | LOCAL_VERSION=$(node -p "require('./package.json').version") 32 | PACKAGE_NAME=$(node -p "require('./package.json').name") 33 | NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") 34 | 35 | echo "Local version: $LOCAL_VERSION" 36 | echo "NPM version: $NPM_VERSION" 37 | 38 | if [ "$LOCAL_VERSION" != "$NPM_VERSION" ] && [ "$(printf '%s\n' "$NPM_VERSION" "$LOCAL_VERSION" | sort -V | head -n 1)" = "$NPM_VERSION" ]; then 39 | echo "Version $LOCAL_VERSION is greater than $NPM_VERSION. Preparing to publish." 40 | echo "should_publish=true" >> "$GITHUB_OUTPUT" 41 | else 42 | echo "Version $LOCAL_VERSION is not greater than $NPM_VERSION. Skipping publish." 43 | echo "should_publish=false" >> "$GITHUB_OUTPUT" 44 | fi 45 | 46 | - name: Build package 47 | if: steps.check_version.outputs.should_publish == 'true' 48 | run: npm run build 49 | 50 | - name: Publish to NPM 51 | if: steps.check_version.outputs.should_publish == 'true' 52 | run: npm publish 53 | env: 54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 55 | 56 | - name: Final Status 57 | if: always() 58 | run: | 59 | if [[ '${{ steps.check_version.outputs.should_publish }}' == 'true' ]]; then 60 | echo "Publish process completed." 61 | else 62 | echo "No new version to publish." 63 | fi 64 | -------------------------------------------------------------------------------- /.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="trpc-to-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 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.tabSize": 2, 5 | "editor.formatOnSave": true, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], 10 | "[javascript]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[jsonc]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | - v2.3.1 4 | - fix: meta can be undefined 5 | 6 | - v2.3.0 7 | - feat(generator): add filter option to selectively include procedures in OpenAPI output 8 | 9 | - v2.2.0 10 | - Upgrade to tRPC 11.1.0 11 | 12 | - v2.1.5 13 | - fix(fastify): send raw request in http handler https://github.com/mcampa/trpc-to-openapi/pull/63. Contribution by [@meriadec](https://github.com/meriadec) 14 | 15 | - v2.1.4 16 | - Koa adapter https://github.com/mcampa/trpc-to-openapi/pull/47. Contribution by [@danperkins](https://github.com/danperkins) 17 | - Fix for Fastify adapter https://github.com/mcampa/trpc-to-openapi/pull/56. Contribution by [@natejohnson05](https://github.com/natejohnson05) 18 | 19 | - v2.1.3 20 | 21 | - Export all internals https://github.com/mcampa/trpc-to-openapi/pull/44. Contribution by [@bkniffler](https://github.com/bkniffler) 22 | - CVE fixes by running npm audit fix. 23 | 24 | - v2.1.2 25 | 26 | - bug fix: remove lodash.cloneDeep from the build output 27 | 28 | - v2.1.1 (bad build, do not use) 29 | 30 | - chore: remove lodash.cloneDeep and update some dependencies 31 | 32 | - v2.1.0 33 | 34 | - Updated the minimum version of `zod-openapi` to 4.1.0. 35 | - Changed `zod-openapi` to a peer dependency. 36 | - The `protect` option now defaults to `true`. 37 | - Improved Error schema titles 38 | 39 | - v2.0.4 40 | 41 | - Upgraded to tRPC 11.0.0-rc.648. 42 | 43 | - v2.0.3 44 | 45 | - Added support for array inputs in GET requests. 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2024 Mario Campa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /assets/trpc-openapi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/with-express/README.md: -------------------------------------------------------------------------------- 1 | # [**`trpc-to-openapi`**](../../README.md) (with-express) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-to-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": "^11.1.0", 10 | "cors": "^2.8.5", 11 | "express": "^4.18.2", 12 | "jsonwebtoken": "^9.0.0", 13 | "swagger-ui-express": "^4.6.3", 14 | "uuid": "^11.0.3", 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": "^22", 22 | "@types/swagger-ui-express": "^4.1.8", 23 | "@types/uuid": "^9.0.1", 24 | "ts-node": "^10.9.1", 25 | "ts-node-dev": "^2.0.0", 26 | "typescript": "5.7.2" 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@example.com', 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 { createExpressMiddleware } from '@trpc/server/adapters/express'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | import swaggerUi from 'swagger-ui-express'; 6 | import { createOpenApiExpressMiddleware } from 'trpc-to-openapi'; 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 'trpc-to-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/mcampa/trpc-to-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-express/src/router.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import { CreateExpressContextOptions } from '@trpc/server/adapters/express'; 3 | import jwt from 'jsonwebtoken'; 4 | import { OpenApiMeta } from 'trpc-to-openapi'; 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 | # [**`trpc-to-openapi`**](../../README.md) (with-fastify) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-to-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": "^9.4.0", 11 | "@trpc/server": "^11.1.0", 12 | "fastify": "^5.1.0", 13 | "jsonwebtoken": "^9.0.0", 14 | "uuid": "^11.0.3", 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": "^22.15.14", 22 | "@types/uuid": "^9.0.1", 23 | "ts-node": "^10.9.1", 24 | "ts-node-dev": "^2.0.0", 25 | "typescript": "5.7.2" 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@example.com', 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 { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; 11 | import Fastify from 'fastify'; 12 | import { fastifyTRPCOpenApiPlugin } from 'trpc-to-openapi'; 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 'trpc-to-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/mcampa/trpc-to-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-fastify/src/router.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-floating-promises, @typescript-eslint/ban-ts-comment */ 2 | import { TRPCError, initTRPC } from '@trpc/server'; 3 | import { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; 4 | import { type FastifyReply, type FastifyRequest } from 'fastify'; 5 | import jwt from 'jsonwebtoken'; 6 | import { OpenApiMeta } from 'trpc-to-openapi'; 7 | import { v4 as uuid } from 'uuid'; 8 | import { z } from 'zod'; 9 | 10 | import { Post, User, database } from './database'; 11 | 12 | const jwtSecret = uuid(); 13 | 14 | export type Context = { 15 | user: User | null; 16 | requestId: string; 17 | req: FastifyRequest; 18 | res: FastifyReply; 19 | }; 20 | 21 | const t = initTRPC 22 | .context() 23 | .meta() 24 | .create({ 25 | errorFormatter: ({ error, shape }) => { 26 | if (error.code === 'INTERNAL_SERVER_ERROR' && process.env.NODE_ENV === 'production') { 27 | return { ...shape, message: 'Internal server error' }; 28 | } 29 | return shape; 30 | }, 31 | }); 32 | 33 | export const createContext = async ({ 34 | req, 35 | res, 36 | }: // eslint-disable-next-line @typescript-eslint/require-await 37 | CreateFastifyContextOptions): Promise => { 38 | const requestId = uuid(); 39 | res.header('x-request-id', requestId); 40 | 41 | let user: User | null = null; 42 | 43 | try { 44 | if (req.headers.authorization) { 45 | const token = req.headers.authorization.split(' ')[1]; 46 | const userId = jwt.verify(token, jwtSecret) as string; 47 | if (userId) { 48 | user = database.users.find((_user) => _user.id === userId) ?? null; 49 | } 50 | } 51 | } catch (cause) { 52 | console.error(cause); 53 | } 54 | 55 | return { req, res, user, requestId }; 56 | }; 57 | 58 | const publicProcedure = t.procedure; 59 | const protectedProcedure = t.procedure.use(({ ctx, next }) => { 60 | if (!ctx.user) { 61 | throw new TRPCError({ 62 | message: 'User not found', 63 | code: 'UNAUTHORIZED', 64 | }); 65 | } 66 | return next({ ctx: { ...ctx, user: ctx.user } }); 67 | }); 68 | 69 | const authRouter = t.router({ 70 | register: publicProcedure 71 | .meta({ 72 | openapi: { 73 | method: 'POST', 74 | path: '/auth/register', 75 | tags: ['auth'], 76 | summary: 'Register as a new user', 77 | }, 78 | }) 79 | .input( 80 | z.object({ 81 | email: z.string().email(), 82 | passcode: z.string().regex(/^[0-9]{4}$/), 83 | name: z.string().min(3), 84 | }), 85 | ) 86 | .output( 87 | z.object({ 88 | user: z.object({ 89 | id: z.string().uuid(), 90 | email: z.string().email(), 91 | name: z.string().min(3), 92 | }), 93 | }), 94 | ) 95 | .mutation(({ input }) => { 96 | let user = database.users.find((_user) => _user.email === input.email); 97 | 98 | if (user) { 99 | throw new TRPCError({ 100 | message: 'User with email already exists', 101 | code: 'UNAUTHORIZED', 102 | }); 103 | } 104 | 105 | user = { 106 | id: uuid(), 107 | email: input.email, 108 | passcode: input.passcode, 109 | name: input.name, 110 | }; 111 | 112 | database.users.push(user); 113 | 114 | return { user: { id: user.id, email: user.email, name: user.name } }; 115 | }), 116 | login: publicProcedure 117 | .meta({ 118 | openapi: { 119 | method: 'POST', 120 | path: '/auth/login', 121 | tags: ['auth'], 122 | summary: 'Login as an existing user', 123 | }, 124 | }) 125 | .input( 126 | z.object({ 127 | email: z.string().email(), 128 | passcode: z.string().regex(/^[0-9]{4}$/), 129 | }), 130 | ) 131 | .output( 132 | z.object({ 133 | token: z.string(), 134 | }), 135 | ) 136 | .mutation(({ input }) => { 137 | const user = database.users.find((_user) => _user.email === input.email); 138 | 139 | if (!user) { 140 | throw new TRPCError({ 141 | message: 'User with email not found', 142 | code: 'UNAUTHORIZED', 143 | }); 144 | } 145 | if (user.passcode !== input.passcode) { 146 | throw new TRPCError({ 147 | message: 'Passcode was incorrect', 148 | code: 'UNAUTHORIZED', 149 | }); 150 | } 151 | 152 | return { 153 | token: jwt.sign(user.id, jwtSecret), 154 | }; 155 | }), 156 | }); 157 | 158 | const usersRouter = t.router({ 159 | getUsers: publicProcedure 160 | .meta({ 161 | openapi: { 162 | method: 'GET', 163 | path: '/users', 164 | tags: ['users'], 165 | summary: 'Read all users', 166 | }, 167 | }) 168 | .input(z.void()) 169 | .output( 170 | z.object({ 171 | users: z.array( 172 | z.object({ 173 | id: z.string().uuid(), 174 | email: z.string().email(), 175 | name: z.string(), 176 | }), 177 | ), 178 | }), 179 | ) 180 | .query(() => { 181 | const users = database.users.map((user) => ({ 182 | id: user.id, 183 | email: user.email, 184 | name: user.name, 185 | })); 186 | 187 | return { users }; 188 | }), 189 | getUserById: publicProcedure 190 | .meta({ 191 | openapi: { 192 | method: 'GET', 193 | path: '/users/{id}', 194 | tags: ['users'], 195 | summary: 'Read a user by id', 196 | }, 197 | }) 198 | .input( 199 | z.object({ 200 | id: z.string().uuid(), 201 | }), 202 | ) 203 | .output( 204 | z.object({ 205 | user: z.object({ 206 | id: z.string().uuid(), 207 | email: z.string().email(), 208 | name: z.string(), 209 | }), 210 | }), 211 | ) 212 | .query(({ input }) => { 213 | const user = database.users.find((_user) => _user.id === input.id); 214 | 215 | if (!user) { 216 | throw new TRPCError({ 217 | message: 'User not found', 218 | code: 'NOT_FOUND', 219 | }); 220 | } 221 | 222 | return { user }; 223 | }), 224 | }); 225 | 226 | const postsRouter = t.router({ 227 | getPosts: publicProcedure 228 | .meta({ 229 | openapi: { 230 | method: 'GET', 231 | path: '/posts', 232 | tags: ['posts'], 233 | summary: 'Read all posts', 234 | }, 235 | }) 236 | .input( 237 | z.object({ 238 | userId: z.string().uuid().optional(), 239 | }), 240 | ) 241 | .output( 242 | z.object({ 243 | posts: z.array( 244 | z.object({ 245 | id: z.string().uuid(), 246 | content: z.string(), 247 | userId: z.string().uuid(), 248 | }), 249 | ), 250 | }), 251 | ) 252 | .query(({ input }) => { 253 | let posts: Post[] = database.posts; 254 | 255 | if (input.userId) { 256 | posts = posts.filter((post) => { 257 | return post.userId === input.userId; 258 | }); 259 | } 260 | 261 | return { posts }; 262 | }), 263 | getPostById: publicProcedure 264 | .meta({ 265 | openapi: { 266 | method: 'GET', 267 | path: '/posts/{id}', 268 | tags: ['posts'], 269 | summary: 'Read a post by id', 270 | }, 271 | }) 272 | .input( 273 | z.object({ 274 | id: z.string().uuid(), 275 | }), 276 | ) 277 | .output( 278 | z.object({ 279 | post: z.object({ 280 | id: z.string().uuid(), 281 | content: z.string(), 282 | userId: z.string().uuid(), 283 | }), 284 | }), 285 | ) 286 | .query(({ input }) => { 287 | const post = database.posts.find((_post) => _post.id === input.id); 288 | 289 | if (!post) { 290 | throw new TRPCError({ 291 | message: 'Post not found', 292 | code: 'NOT_FOUND', 293 | }); 294 | } 295 | 296 | return { post }; 297 | }), 298 | createPost: protectedProcedure 299 | .meta({ 300 | openapi: { 301 | method: 'POST', 302 | path: '/posts', 303 | tags: ['posts'], 304 | protect: true, 305 | summary: 'Create a new post', 306 | }, 307 | }) 308 | .input( 309 | z.object({ 310 | content: z.string().min(1).max(140), 311 | }), 312 | ) 313 | .output( 314 | z.object({ 315 | post: z.object({ 316 | id: z.string().uuid(), 317 | content: z.string(), 318 | userId: z.string().uuid(), 319 | }), 320 | }), 321 | ) 322 | .mutation(({ input, ctx }) => { 323 | const post: Post = { 324 | id: uuid(), 325 | content: input.content, 326 | userId: ctx.user.id, 327 | }; 328 | 329 | database.posts.push(post); 330 | 331 | return { post }; 332 | }), 333 | updatePostById: protectedProcedure 334 | .meta({ 335 | openapi: { 336 | method: 'PUT', 337 | path: '/posts/{id}', 338 | tags: ['posts'], 339 | protect: true, 340 | summary: 'Update an existing post', 341 | }, 342 | }) 343 | .input( 344 | z.object({ 345 | id: z.string().uuid(), 346 | content: z.string().min(1), 347 | }), 348 | ) 349 | .output( 350 | z.object({ 351 | post: z.object({ 352 | id: z.string().uuid(), 353 | content: z.string(), 354 | userId: z.string().uuid(), 355 | }), 356 | }), 357 | ) 358 | .mutation(({ input, ctx }) => { 359 | const post = database.posts.find((_post) => _post.id === input.id); 360 | 361 | if (!post) { 362 | throw new TRPCError({ 363 | message: 'Post not found', 364 | code: 'NOT_FOUND', 365 | }); 366 | } 367 | if (post.userId !== ctx.user.id) { 368 | throw new TRPCError({ 369 | message: 'Cannot edit post owned by other user', 370 | code: 'FORBIDDEN', 371 | }); 372 | } 373 | 374 | post.content = input.content; 375 | 376 | return { post }; 377 | }), 378 | deletePostById: protectedProcedure 379 | .meta({ 380 | openapi: { 381 | method: 'DELETE', 382 | path: '/posts/{id}', 383 | tags: ['posts'], 384 | protect: true, 385 | summary: 'Delete a post', 386 | }, 387 | }) 388 | .input( 389 | z.object({ 390 | id: z.string().uuid(), 391 | }), 392 | ) 393 | .output(z.null()) 394 | .mutation(({ input, ctx }) => { 395 | const post = database.posts.find((_post) => _post.id === input.id); 396 | 397 | if (!post) { 398 | throw new TRPCError({ 399 | message: 'Post not found', 400 | code: 'NOT_FOUND', 401 | }); 402 | } 403 | if (post.userId !== ctx.user.id) { 404 | throw new TRPCError({ 405 | message: 'Cannot delete post owned by other user', 406 | code: 'FORBIDDEN', 407 | }); 408 | } 409 | 410 | database.posts = database.posts.filter((_post) => _post !== post); 411 | 412 | return null; 413 | }), 414 | }); 415 | 416 | export const appRouter = t.router({ 417 | auth: authRouter, 418 | users: usersRouter, 419 | posts: postsRouter, 420 | }); 421 | 422 | export type AppRouter = typeof appRouter; 423 | -------------------------------------------------------------------------------- /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 | # [**`trpc-to-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 `trpc-to-openapi`. 6 | 7 | ```bash 8 | npm install @trpc/server@next 9 | npm install trpc-to-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": "^11.1.0", 7 | "zod": "^3.21.4" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^22", 11 | "typescript": "5.7.2" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/with-interop/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from '@trpc/server'; 2 | import { OpenApiMeta } from 'trpc-to-openapi'; 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` && `trpc-to-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 | # [**`trpc-to-openapi`**](../../README.md) (with-nextjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-to-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-nextjs 11 | ``` 12 | 13 | D 14 | -------------------------------------------------------------------------------- /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": "^11.1.0", 13 | "jsonwebtoken": "^9.0.0", 14 | "next": "^15.3.1", 15 | "nextjs-cors": "^2.1.2", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "swagger-ui-react": "^5.18.2", 19 | "uuid": "^11.0.3", 20 | "zod": "^3.21.4" 21 | }, 22 | "devDependencies": { 23 | "@types/jsonwebtoken": "^9.0.2", 24 | "@types/node": "^22", 25 | "@types/react": "^18.2.6", 26 | "@types/react-dom": "^18.2.4", 27 | "@types/swagger-ui-react": "^5.18.0", 28 | "@types/uuid": "^9.0.1", 29 | "typescript": "5.7.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcampa/trpc-to-openapi/38ea63308adf9fa51f7f0a189aace589f0ce3b17/examples/with-nextjs-appdir/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/src/app/api/[...trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiFetchHandler } from 'trpc-to-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@example.com', 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 'trpc-to-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/mcampa/trpc-to-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nextjs-appdir/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 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /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 | # [**`trpc-to-openapi`**](../../README.md) (with-nextjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-to-openapi` root. 6 | 7 | ```bash 8 | npm install 9 | npm run build 10 | npm run dev -w with-nextjs 11 | ``` 12 | 13 | D 14 | -------------------------------------------------------------------------------- /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": "^11.1.0", 13 | "jsonwebtoken": "^9.0.0", 14 | "next": "^15.3.1", 15 | "nextjs-cors": "^2.1.2", 16 | "react": "^19", 17 | "react-dom": "^19", 18 | "swagger-ui-react": "5.21.0", 19 | "uuid": "^11.0.3", 20 | "zod": "^3.21.4" 21 | }, 22 | "devDependencies": { 23 | "@types/jsonwebtoken": "^9.0.2", 24 | "@types/node": "^22", 25 | "@types/react": "^19", 26 | "@types/react-dom": "^19", 27 | "@types/swagger-ui-react": "5.18.0", 28 | "@types/uuid": "^9.0.1", 29 | "typescript": "5.7.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/with-nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcampa/trpc-to-openapi/38ea63308adf9fa51f7f0a189aace589f0ce3b17/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 { NextApiRequest, NextApiResponse } from 'next'; 2 | import cors from 'nextjs-cors'; 3 | import { createOpenApiNextHandler } from 'trpc-to-openapi'; 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@example.com', 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 'trpc-to-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/mcampa/trpc-to-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nextjs/src/server/router.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import { CreateNextContextOptions } from '@trpc/server/adapters/next'; 3 | import jwt from 'jsonwebtoken'; 4 | import { OpenApiMeta } from 'trpc-to-openapi'; 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 | # [**`trpc-to-openapi`**](../../README.md) (with-nuxtjs) 2 | 3 | ### Getting started 4 | 5 | Make sure your current working directory is at `/trpc-to-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": "^22", 13 | "nuxt": "^3.5.1" 14 | }, 15 | "dependencies": { 16 | "@trpc/server": "^11.1.0", 17 | "trpc-nuxt": "^0.11.0-beta.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcampa/trpc-to-openapi/38ea63308adf9fa51f7f0a189aace589f0ce3b17/examples/with-nuxtjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/api/[...trpc].ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiNuxtHandler } from 'trpc-to-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@example.com', 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 'trpc-to-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/mcampa/trpc-to-openapi', 12 | tags: ['auth', 'users', 'posts'], 13 | }); 14 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/server/router.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import { IncomingMessage, ServerResponse } from 'http'; 3 | import jwt from 'jsonwebtoken'; 4 | import { OpenApiMeta } from 'trpc-to-openapi'; 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 ({ 31 | req, 32 | res, 33 | }: { 34 | req: IncomingMessage; 35 | res: ServerResponse; 36 | // eslint-disable-next-line @typescript-eslint/require-await 37 | }) => { 38 | const requestId = uuid(); 39 | res.setHeader('x-request-id', requestId); 40 | 41 | let user: User | null = null; 42 | 43 | try { 44 | if (req.headers.authorization) { 45 | const token = req.headers.authorization.split(' ')[1]; 46 | const userId = jwt.verify(token, jwtSecret) as string; 47 | if (userId) { 48 | user = database.users.find((_user) => _user.id === userId) ?? null; 49 | } 50 | } 51 | } catch (cause) { 52 | console.error(cause); 53 | } 54 | 55 | return { user, requestId }; 56 | }; 57 | 58 | const publicProcedure = t.procedure; 59 | const protectedProcedure = t.procedure.use(({ ctx, next }) => { 60 | if (!ctx.user) { 61 | throw new TRPCError({ 62 | message: 'User not found', 63 | code: 'UNAUTHORIZED', 64 | }); 65 | } 66 | return next({ ctx: { ...ctx, user: ctx.user } }); 67 | }); 68 | 69 | const authRouter = t.router({ 70 | register: publicProcedure 71 | .meta({ 72 | openapi: { 73 | method: 'POST', 74 | path: '/auth/register', 75 | tags: ['auth'], 76 | summary: 'Register as a new user', 77 | }, 78 | }) 79 | .input( 80 | z.object({ 81 | email: z.string().email(), 82 | passcode: z.preprocess( 83 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 84 | z.number().min(1000).max(9999), 85 | ), 86 | name: z.string().min(3), 87 | }), 88 | ) 89 | .output( 90 | z.object({ 91 | user: z.object({ 92 | id: z.string().uuid(), 93 | email: z.string().email(), 94 | name: z.string().min(3), 95 | }), 96 | }), 97 | ) 98 | .mutation(({ input }) => { 99 | let user = database.users.find((_user) => _user.email === input.email); 100 | 101 | if (user) { 102 | throw new TRPCError({ 103 | message: 'User with email already exists', 104 | code: 'UNAUTHORIZED', 105 | }); 106 | } 107 | 108 | user = { 109 | id: uuid(), 110 | email: input.email, 111 | passcode: input.passcode, 112 | name: input.name, 113 | }; 114 | 115 | database.users.push(user); 116 | 117 | return { user: { id: user.id, email: user.email, name: user.name } }; 118 | }), 119 | login: publicProcedure 120 | .meta({ 121 | openapi: { 122 | method: 'POST', 123 | path: '/auth/login', 124 | tags: ['auth'], 125 | summary: 'Login as an existing user', 126 | }, 127 | }) 128 | .input( 129 | z.object({ 130 | email: z.string().email(), 131 | passcode: z.preprocess( 132 | (arg) => (typeof arg === 'string' ? parseInt(arg) : arg), 133 | z.number().min(1000).max(9999), 134 | ), 135 | }), 136 | ) 137 | .output( 138 | z.object({ 139 | token: z.string(), 140 | }), 141 | ) 142 | .mutation(({ input }) => { 143 | const user = database.users.find((_user) => _user.email === input.email); 144 | 145 | if (!user) { 146 | throw new TRPCError({ 147 | message: 'User with email not found', 148 | code: 'UNAUTHORIZED', 149 | }); 150 | } 151 | if (user.passcode !== input.passcode) { 152 | throw new TRPCError({ 153 | message: 'Passcode was incorrect', 154 | code: 'UNAUTHORIZED', 155 | }); 156 | } 157 | 158 | return { 159 | token: jwt.sign(user.id, jwtSecret), 160 | }; 161 | }), 162 | }); 163 | 164 | const usersRouter = t.router({ 165 | getUsers: publicProcedure 166 | .meta({ 167 | openapi: { 168 | method: 'GET', 169 | path: '/users', 170 | tags: ['users'], 171 | summary: 'Read all users', 172 | }, 173 | }) 174 | .input(z.void()) 175 | .output( 176 | z.object({ 177 | users: z.array( 178 | z.object({ 179 | id: z.string().uuid(), 180 | email: z.string().email(), 181 | name: z.string(), 182 | }), 183 | ), 184 | }), 185 | ) 186 | .query(() => { 187 | const users = database.users.map((user) => ({ 188 | id: user.id, 189 | email: user.email, 190 | name: user.name, 191 | })); 192 | 193 | return { users }; 194 | }), 195 | getUserById: publicProcedure 196 | .meta({ 197 | openapi: { 198 | method: 'GET', 199 | path: '/users/{id}', 200 | tags: ['users'], 201 | summary: 'Read a user by id', 202 | }, 203 | }) 204 | .input( 205 | z.object({ 206 | id: z.string().uuid(), 207 | }), 208 | ) 209 | .output( 210 | z.object({ 211 | user: z.object({ 212 | id: z.string().uuid(), 213 | email: z.string().email(), 214 | name: z.string(), 215 | }), 216 | }), 217 | ) 218 | .query(({ input }) => { 219 | const user = database.users.find((_user) => _user.id === input.id); 220 | 221 | if (!user) { 222 | throw new TRPCError({ 223 | message: 'User not found', 224 | code: 'NOT_FOUND', 225 | }); 226 | } 227 | 228 | return { user }; 229 | }), 230 | }); 231 | 232 | const postsRouter = t.router({ 233 | getPosts: publicProcedure 234 | .meta({ 235 | openapi: { 236 | method: 'GET', 237 | path: '/posts', 238 | tags: ['posts'], 239 | summary: 'Read all posts', 240 | }, 241 | }) 242 | .input( 243 | z.object({ 244 | userId: z.string().uuid().optional(), 245 | }), 246 | ) 247 | .output( 248 | z.object({ 249 | posts: z.array( 250 | z.object({ 251 | id: z.string().uuid(), 252 | content: z.string(), 253 | userId: z.string().uuid(), 254 | }), 255 | ), 256 | }), 257 | ) 258 | .query(({ input }) => { 259 | let posts: Post[] = database.posts; 260 | 261 | if (input.userId) { 262 | posts = posts.filter((post) => { 263 | return post.userId === input.userId; 264 | }); 265 | } 266 | 267 | return { posts }; 268 | }), 269 | getPostById: publicProcedure 270 | .meta({ 271 | openapi: { 272 | method: 'GET', 273 | path: '/posts/{id}', 274 | tags: ['posts'], 275 | summary: 'Read a post by id', 276 | }, 277 | }) 278 | .input( 279 | z.object({ 280 | id: z.string().uuid(), 281 | }), 282 | ) 283 | .output( 284 | z.object({ 285 | post: z.object({ 286 | id: z.string().uuid(), 287 | content: z.string(), 288 | userId: z.string().uuid(), 289 | }), 290 | }), 291 | ) 292 | .query(({ input }) => { 293 | const post = database.posts.find((_post) => _post.id === input.id); 294 | 295 | if (!post) { 296 | throw new TRPCError({ 297 | message: 'Post not found', 298 | code: 'NOT_FOUND', 299 | }); 300 | } 301 | 302 | return { post }; 303 | }), 304 | createPost: protectedProcedure 305 | .meta({ 306 | openapi: { 307 | method: 'POST', 308 | path: '/posts', 309 | tags: ['posts'], 310 | protect: true, 311 | summary: 'Create a new post', 312 | }, 313 | }) 314 | .input( 315 | z.object({ 316 | content: z.string().min(1).max(140), 317 | }), 318 | ) 319 | .output( 320 | z.object({ 321 | post: z.object({ 322 | id: z.string().uuid(), 323 | content: z.string(), 324 | userId: z.string().uuid(), 325 | }), 326 | }), 327 | ) 328 | .mutation(({ input, ctx }) => { 329 | const post: Post = { 330 | id: uuid(), 331 | content: input.content, 332 | userId: ctx.user.id, 333 | }; 334 | 335 | database.posts.push(post); 336 | 337 | return { post }; 338 | }), 339 | updatePostById: protectedProcedure 340 | .meta({ 341 | openapi: { 342 | method: 'PUT', 343 | path: '/posts/{id}', 344 | tags: ['posts'], 345 | protect: true, 346 | summary: 'Update an existing post', 347 | }, 348 | }) 349 | .input( 350 | z.object({ 351 | id: z.string().uuid(), 352 | content: z.string().min(1), 353 | }), 354 | ) 355 | .output( 356 | z.object({ 357 | post: z.object({ 358 | id: z.string().uuid(), 359 | content: z.string(), 360 | userId: z.string().uuid(), 361 | }), 362 | }), 363 | ) 364 | .mutation(({ input, ctx }) => { 365 | const post = database.posts.find((_post) => _post.id === input.id); 366 | 367 | if (!post) { 368 | throw new TRPCError({ 369 | message: 'Post not found', 370 | code: 'NOT_FOUND', 371 | }); 372 | } 373 | if (post.userId !== ctx.user.id) { 374 | throw new TRPCError({ 375 | message: 'Cannot edit post owned by other user', 376 | code: 'FORBIDDEN', 377 | }); 378 | } 379 | 380 | post.content = input.content; 381 | 382 | return { post }; 383 | }), 384 | deletePostById: protectedProcedure 385 | .meta({ 386 | openapi: { 387 | method: 'DELETE', 388 | path: '/posts/{id}', 389 | tags: ['posts'], 390 | protect: true, 391 | summary: 'Delete a post', 392 | }, 393 | }) 394 | .input( 395 | z.object({ 396 | id: z.string().uuid(), 397 | }), 398 | ) 399 | .output(z.null()) 400 | .mutation(({ input, ctx }) => { 401 | const post = database.posts.find((_post) => _post.id === input.id); 402 | 403 | if (!post) { 404 | throw new TRPCError({ 405 | message: 'Post not found', 406 | code: 'NOT_FOUND', 407 | }); 408 | } 409 | if (post.userId !== ctx.user.id) { 410 | throw new TRPCError({ 411 | message: 'Cannot delete post owned by other user', 412 | code: 'FORBIDDEN', 413 | }); 414 | } 415 | 416 | database.posts = database.posts.filter((_post) => _post !== post); 417 | 418 | return null; 419 | }), 420 | }); 421 | 422 | export const appRouter = t.router({ 423 | auth: authRouter, 424 | users: usersRouter, 425 | posts: postsRouter, 426 | }); 427 | 428 | export type AppRouter = typeof appRouter; 429 | -------------------------------------------------------------------------------- /examples/with-nuxtjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /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 | prettierPath: require.resolve('formatter-for-jest-snapshots'), 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trpc-to-openapi", 3 | "version": "2.3.1", 4 | "description": "tRPC OpenAPI", 5 | "author": "mcampa", 6 | "private": false, 7 | "license": "MIT", 8 | "keywords": [ 9 | "trpc", 10 | "openapi", 11 | "swagger" 12 | ], 13 | "homepage": "https://github.com/mcampa/trpc-to-openapi", 14 | "repository": "github:mcampa/trpc-to-openapi", 15 | "bugs": "https://github.com/mcampa/trpc-to-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-appdir", 33 | "examples/with-nextjs", 34 | "examples/with-express", 35 | "examples/with-interop", 36 | "examples/with-serverless", 37 | "examples/with-fastify", 38 | "examples/with-nuxtjs" 39 | ], 40 | "scripts": { 41 | "t": "jest", 42 | "test": "tsc --noEmit && jest --verbose", 43 | "lint": "eslint . --ext .ts", 44 | "lint-fix": "eslint . --ext .ts --fix", 45 | "format": "prettier --write ./src ./test ./examples", 46 | "build": "npm test && rimraf dist && npm run build:cjs && npm run build:esm", 47 | "build:cjs": "tsc -p tsconfig.build.cjs.json", 48 | "build:esm": "tsc -p tsconfig.build.esm.json", 49 | "postbuild": "node rename.js" 50 | }, 51 | "peerDependencies": { 52 | "@trpc/server": "^11.1.0", 53 | "zod": "^3.23.8", 54 | "zod-openapi": "4.2.4" 55 | }, 56 | "dependencies": { 57 | "co-body": "6.2.0", 58 | "h3": "1.15.1", 59 | "openapi3-ts": "4.4.0" 60 | }, 61 | "devDependencies": { 62 | "@trpc/client": "^11.1.0", 63 | "@types/aws-lambda": "^8.10.115", 64 | "@types/co-body": "^6.1.0", 65 | "@types/express": "^4.17.17", 66 | "@types/jest": "^29.5.1", 67 | "@types/koa": "^2.15.0", 68 | "@types/node": "^22", 69 | "@types/node-fetch": "^2.6.12", 70 | "@typescript-eslint/eslint-plugin": "^7.18.0", 71 | "@typescript-eslint/parser": "^7.18.0", 72 | "eslint": "^8.57.0", 73 | "express": "^4.18.2", 74 | "fastify": "^5.1.0", 75 | "formatter-for-jest-snapshots": "npm:prettier@^2", 76 | "jest": "^29.5.0", 77 | "koa": "^2.15.3", 78 | "next": "^15.3.1", 79 | "node-fetch": "^2.6.11", 80 | "node-mocks-http": "^1.12.2", 81 | "openapi-schema-validator": "^12.1.1", 82 | "prettier": "^3.4.1", 83 | "rimraf": "^6.0.1", 84 | "superjson": "^1.12.3", 85 | "ts-jest": "^29.1.0", 86 | "ts-node": "^10.9.1", 87 | "typescript": "5.7.2", 88 | "zod-openapi": "4.2.4" 89 | }, 90 | "optionalDependencies": { 91 | "@rollup/rollup-linux-x64-gnu": "4.6.1" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /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 | }; 13 | -------------------------------------------------------------------------------- /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/express.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { OpenApiRouter } from '../types'; 4 | import { CreateOpenApiNodeHttpHandlerOptions, createOpenApiNodeHttpHandler } from './node-http'; 5 | 6 | export type CreateOpenApiExpressMiddlewareOptions = 7 | CreateOpenApiNodeHttpHandlerOptions; 8 | 9 | export const createOpenApiExpressMiddleware = ( 10 | opts: CreateOpenApiExpressMiddlewareOptions, 11 | ) => { 12 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 13 | 14 | return async (req: Request, res: Response) => { 15 | await openApiHttpHandler(req, res); 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/adapters/fastify.ts: -------------------------------------------------------------------------------- 1 | import { AnyRouter } from '@trpc/server'; 2 | import { FastifyInstance } from 'fastify'; 3 | 4 | import { OpenApiRouter } from '../types'; 5 | import { CreateOpenApiNodeHttpHandlerOptions, createOpenApiNodeHttpHandler } from './node-http'; 6 | 7 | export type CreateOpenApiFastifyPluginOptions = 8 | CreateOpenApiNodeHttpHandlerOptions & { 9 | basePath?: `/${string}`; 10 | }; 11 | 12 | export function fastifyTRPCOpenApiPlugin( 13 | fastify: FastifyInstance, 14 | opts: CreateOpenApiFastifyPluginOptions, 15 | done: (err?: Error) => void, 16 | ) { 17 | let prefix = opts.basePath ?? ''; 18 | 19 | // if prefix ends with a slash, remove it 20 | if (prefix.endsWith('/')) { 21 | prefix = prefix.slice(0, -1); 22 | } 23 | 24 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 25 | 26 | fastify.all(`${prefix}/*`, async (request, reply) => { 27 | const prefixRemovedFromUrl = request.url.replace(fastify.prefix, '').replace(prefix, ''); 28 | request.raw.url = prefixRemovedFromUrl; 29 | return await openApiHttpHandler(request, reply.raw); 30 | }); 31 | 32 | done(); 33 | } 34 | -------------------------------------------------------------------------------- /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 { createOpenApiNodeHttpHandler } from './node-http'; 7 | 8 | export type CreateOpenApiFetchHandlerOptions = Omit< 9 | FetchHandlerOptions, 10 | 'batching' 11 | > & { 12 | req: Request; 13 | endpoint: `/${string}`; 14 | }; 15 | 16 | const getUrlEncodedBody = async (req: Request) => { 17 | const params = new URLSearchParams(await req.text()); 18 | 19 | const data: Record = {}; 20 | 21 | for (const key of params.keys()) { 22 | data[key] = params.getAll(key); 23 | } 24 | 25 | return data; 26 | }; 27 | 28 | // co-body does not parse Request body correctly 29 | const getRequestBody = async (req: Request) => { 30 | try { 31 | if (req.headers.get('content-type')?.includes('application/json')) { 32 | return { 33 | isValid: true, 34 | // use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON 35 | data: JSON.parse(await req.text()), 36 | }; 37 | } 38 | 39 | if (req.headers.get('content-type')?.includes('application/x-www-form-urlencoded')) { 40 | return { 41 | isValid: true, 42 | data: await getUrlEncodedBody(req), 43 | }; 44 | } 45 | 46 | return { 47 | isValid: true, 48 | data: req.body, 49 | }; 50 | } catch (err) { 51 | return { 52 | isValid: false, 53 | cause: err, 54 | }; 55 | } 56 | }; 57 | 58 | const createRequestProxy = async (req: Request, url?: string) => { 59 | const body = await getRequestBody(req); 60 | 61 | return new Proxy(req, { 62 | get: (target, prop) => { 63 | if (prop === 'url') { 64 | return url ? url : target.url; 65 | } 66 | 67 | if (prop === 'body') { 68 | if (!body.isValid) { 69 | throw new TRPCError({ 70 | code: 'PARSE_ERROR', 71 | message: 'Failed to parse request body', 72 | cause: body.cause, 73 | }); 74 | } 75 | 76 | return body.data; 77 | } 78 | 79 | return target[prop as keyof typeof target]; 80 | }, 81 | }); 82 | }; 83 | 84 | export const createOpenApiFetchHandler = async ( 85 | opts: CreateOpenApiFetchHandlerOptions, 86 | ): Promise => { 87 | const resHeaders = new Headers(); 88 | const url = new URL(opts.req.url.replace(opts.endpoint, '')); 89 | const req: Request = await createRequestProxy(opts.req, url.toString()); 90 | 91 | // @ts-expect-error FIXME 92 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 93 | 94 | return new Promise((resolve) => { 95 | let statusCode: number; 96 | 97 | const res = { 98 | setHeader: (key: string, value: string | readonly string[]) => { 99 | if (typeof value === 'string') { 100 | resHeaders.set(key, value); 101 | } else { 102 | for (const v of value) { 103 | resHeaders.append(key, v); 104 | } 105 | } 106 | }, 107 | get statusCode() { 108 | return statusCode; 109 | }, 110 | set statusCode(code: number) { 111 | statusCode = code; 112 | }, 113 | end: (body: string) => { 114 | resolve( 115 | new Response(body, { 116 | headers: resHeaders, 117 | status: statusCode, 118 | }), 119 | ); 120 | }, 121 | } as unknown as ServerResponse; 122 | 123 | return openApiHttpHandler(req as unknown as IncomingMessage, res); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './standalone'; 2 | export * from './express'; 3 | export * from './next'; 4 | export * from './fastify'; 5 | export * from './fetch'; 6 | export * from './nuxt'; 7 | export * from './node-http'; 8 | export * from './koa'; 9 | -------------------------------------------------------------------------------- /src/adapters/koa.ts: -------------------------------------------------------------------------------- 1 | import { ParameterizedContext, Next, Middleware } from 'koa'; 2 | import { OpenApiRouter } from '../types'; 3 | import { CreateOpenApiNodeHttpHandlerOptions, createOpenApiNodeHttpHandler } from './node-http'; 4 | 5 | type Request = ParameterizedContext['req']; 6 | type Response = ParameterizedContext['res']; 7 | 8 | export type CreateOpenApiKoaMiddlewareOptions = 9 | CreateOpenApiNodeHttpHandlerOptions; 10 | 11 | export const createOpenApiKoaMiddleware = ( 12 | opts: CreateOpenApiKoaMiddlewareOptions, 13 | ): Middleware => { 14 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 15 | 16 | return async (ctx: ParameterizedContext, next: Next) => { 17 | await openApiHttpHandler(ctx.req, ctx.res, next); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /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'; 6 | import { CreateOpenApiNodeHttpHandlerOptions, createOpenApiNodeHttpHandler } from './node-http'; 7 | 8 | export type CreateOpenApiNextHandlerOptions = 9 | CreateOpenApiNodeHttpHandlerOptions; 10 | 11 | export const createOpenApiNextHandler = ( 12 | opts: CreateOpenApiNextHandlerOptions, 13 | ) => { 14 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 15 | 16 | return async (req: NextApiRequest, res: NextApiResponse) => { 17 | let pathname: string | null = null; 18 | if (typeof req.query.trpc === 'string') { 19 | pathname = req.query.trpc; 20 | } else if (Array.isArray(req.query.trpc)) { 21 | pathname = req.query.trpc.join('/'); 22 | } 23 | 24 | if (pathname === null) { 25 | const error = new TRPCError({ 26 | message: 'Query "trpc" not found - is the `trpc-to-openapi` file named `[...trpc].ts`?', 27 | code: 'INTERNAL_SERVER_ERROR', 28 | }); 29 | 30 | opts.onError?.({ 31 | error, 32 | type: 'unknown', 33 | path: undefined, 34 | input: undefined, 35 | ctx: undefined, 36 | req, 37 | }); 38 | 39 | res.statusCode = 500; 40 | res.setHeader('Content-Type', 'application/json'); 41 | const body: OpenApiErrorResponse = { 42 | message: error.message, 43 | code: error.code, 44 | }; 45 | res.end(JSON.stringify(body)); 46 | 47 | return; 48 | } 49 | 50 | if (!('once' in res)) { 51 | Object.assign(res, { 52 | once: () => undefined, 53 | }); 54 | } 55 | 56 | req.url = normalizePath(pathname); 57 | await openApiHttpHandler(req, res); 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/adapters/node-http/core.ts: -------------------------------------------------------------------------------- 1 | import { type HTTPHeaders } from '@trpc/client'; 2 | import { TRPCError } from '@trpc/server'; 3 | import { 4 | type NodeHTTPHandlerOptions, 5 | type NodeHTTPResponse, 6 | } from '@trpc/server/adapters/node-http'; 7 | import { getErrorShape, TRPCRequestInfo } from '@trpc/server/unstable-core-do-not-import'; 8 | import { ZodArray, ZodError, ZodTypeAny } from 'zod'; 9 | import { NodeHTTPRequest } from '../../types'; 10 | import { generateOpenApiDocument } from '../../generator'; 11 | import { 12 | OpenApiErrorResponse, 13 | OpenApiMethod, 14 | OpenApiProcedure, 15 | OpenApiResponse, 16 | OpenApiRouter, 17 | OpenApiSuccessResponse, 18 | } from '../../types'; 19 | import { 20 | acceptsRequestBody, 21 | normalizePath, 22 | getInputOutputParsers, 23 | coerceSchema, 24 | instanceofZodTypeLikeVoid, 25 | instanceofZodTypeObject, 26 | unwrapZodType, 27 | zodSupportsCoerce, 28 | getContentType, 29 | getRequestSignal, 30 | } from '../../utils'; 31 | import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from './errors'; 32 | import { getBody, getQuery } from './input'; 33 | import { createProcedureCache } from './procedures'; 34 | 35 | export type CreateOpenApiNodeHttpHandlerOptions< 36 | TRouter extends OpenApiRouter, 37 | TRequest extends NodeHTTPRequest, 38 | TResponse extends NodeHTTPResponse, 39 | > = Pick< 40 | NodeHTTPHandlerOptions, 41 | 'router' | 'createContext' | 'responseMeta' | 'onError' | 'maxBodySize' 42 | >; 43 | 44 | export type OpenApiNextFunction = () => void; 45 | 46 | export const createOpenApiNodeHttpHandler = < 47 | TRouter extends OpenApiRouter, 48 | TRequest extends NodeHTTPRequest, 49 | TResponse extends NodeHTTPResponse, 50 | >( 51 | opts: CreateOpenApiNodeHttpHandlerOptions, 52 | ) => { 53 | const router = Object.assign({}, opts.router); 54 | 55 | // Validate router 56 | if (process.env.NODE_ENV !== 'production') { 57 | generateOpenApiDocument(router, { title: '', version: '', baseUrl: '' }); 58 | } 59 | 60 | const { createContext, responseMeta, onError, maxBodySize } = opts; 61 | const getProcedure = createProcedureCache(router); 62 | 63 | return async (req: TRequest, res: TResponse, next?: OpenApiNextFunction) => { 64 | const sendResponse = (statusCode: number, headers: HTTPHeaders, body: OpenApiResponse) => { 65 | res.statusCode = statusCode; 66 | res.setHeader('Content-Type', 'application/json'); 67 | for (const [key, value] of Object.entries(headers)) { 68 | if (typeof value !== 'undefined') { 69 | res.setHeader(key, value as string); 70 | } 71 | } 72 | res.end(JSON.stringify(body)); 73 | }; 74 | 75 | const method = req.method as OpenApiMethod | 'HEAD'; 76 | const reqUrl = req.url!; 77 | const url = new URL(reqUrl.startsWith('/') ? `http://127.0.0.1${reqUrl}` : reqUrl); 78 | const path = normalizePath(url.pathname); 79 | let input: any = undefined; 80 | let ctx: any = undefined; 81 | let info: TRPCRequestInfo | undefined = undefined; 82 | let data: any = undefined; 83 | 84 | const { procedure, pathInput } = getProcedure(method, path) ?? {}; 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 contentType = getContentType(req); 105 | const useBody = acceptsRequestBody(method); 106 | 107 | if (useBody && !contentType?.startsWith('application/json')) { 108 | throw new TRPCError({ 109 | code: 'UNSUPPORTED_MEDIA_TYPE', 110 | message: contentType 111 | ? `Unsupported content-type "${contentType}` 112 | : 'Missing content-type header', 113 | }); 114 | } 115 | 116 | const inputParser = getInputOutputParsers(procedure.procedure).inputParser as ZodTypeAny; 117 | const unwrappedSchema = unwrapZodType(inputParser, true); 118 | 119 | // input should stay undefined if z.void() 120 | if (!instanceofZodTypeLikeVoid(unwrappedSchema)) { 121 | input = { 122 | ...(useBody ? await getBody(req, maxBodySize) : getQuery(req, url)), 123 | ...pathInput, 124 | }; 125 | } 126 | 127 | // if supported, coerce all string values to correct types 128 | if (zodSupportsCoerce && instanceofZodTypeObject(unwrappedSchema)) { 129 | if (!useBody) { 130 | for (const [key, shape] of Object.entries(unwrappedSchema.shape)) { 131 | if (shape instanceof ZodArray && !Array.isArray(input[key])) { 132 | input[key] = [input[key]]; 133 | } 134 | } 135 | } 136 | coerceSchema(unwrappedSchema); 137 | } 138 | 139 | info = { 140 | isBatchCall: false, 141 | accept: null, 142 | calls: [], 143 | type: procedure.type, 144 | connectionParams: null, 145 | signal: getRequestSignal(req, res, maxBodySize), 146 | url, 147 | }; 148 | 149 | ctx = await createContext?.({ req, res, info }); 150 | const caller = router.createCaller(ctx); 151 | 152 | const segments = procedure.path.split('.'); 153 | const procedureFn = segments.reduce( 154 | (acc, curr) => acc[curr], 155 | caller as any, 156 | ) as OpenApiProcedure; 157 | 158 | data = await procedureFn(input); 159 | 160 | const meta = responseMeta?.({ 161 | type: procedure.type, 162 | paths: [procedure.path], 163 | ctx, 164 | data: [data], 165 | errors: [], 166 | info, 167 | eagerGeneration: true, 168 | }); 169 | 170 | const statusCode = meta?.status ?? 200; 171 | const headers = meta?.headers ?? {}; 172 | const body: OpenApiSuccessResponse = data; 173 | sendResponse(statusCode, headers, body); 174 | } catch (cause) { 175 | const error = getErrorFromUnknown(cause); 176 | 177 | onError?.({ 178 | error, 179 | type: procedure?.type ?? 'unknown', 180 | path: procedure?.path, 181 | input, 182 | ctx, 183 | req, 184 | }); 185 | 186 | const meta = responseMeta?.({ 187 | type: procedure?.type ?? 'unknown', 188 | paths: procedure?.path ? [procedure?.path] : undefined, 189 | ctx, 190 | data: [data], 191 | errors: [error], 192 | info, 193 | eagerGeneration: true, 194 | }); 195 | 196 | const errorShape = getErrorShape({ 197 | config: router._def._config, 198 | error, 199 | type: procedure?.type ?? 'unknown', 200 | path: procedure?.path, 201 | input, 202 | ctx, 203 | }); 204 | 205 | const isInputValidationError = 206 | error.code === 'BAD_REQUEST' && 207 | error.cause instanceof Error && 208 | error.cause.name === 'ZodError'; 209 | 210 | const statusCode = meta?.status ?? TRPC_ERROR_CODE_HTTP_STATUS[error.code] ?? 500; 211 | const headers = meta?.headers ?? {}; 212 | const body: OpenApiErrorResponse = { 213 | ...errorShape, // Pass the error through 214 | message: isInputValidationError 215 | ? 'Input validation failed' 216 | : (errorShape?.message ?? error.message ?? 'An error occurred'), 217 | code: error.code, 218 | issues: isInputValidationError ? (error.cause as ZodError).errors : undefined, 219 | }; 220 | sendResponse(statusCode, headers, body); 221 | } 222 | }; 223 | }; 224 | -------------------------------------------------------------------------------- /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 | UNSUPPORTED_MEDIA_TYPE: 415, 17 | TOO_MANY_REQUESTS: 429, 18 | UNPROCESSABLE_CONTENT: 422, 19 | NOT_IMPLEMENTED: 501, 20 | BAD_GATEWAY: 502, 21 | SERVICE_UNAVAILABLE: 503, 22 | GATEWAY_TIMEOUT: 504, 23 | }; 24 | 25 | export const HTTP_STATUS_TRPC_ERROR_CODE: Record = { 26 | 400: 'BAD_REQUEST', 27 | 404: 'NOT_FOUND', 28 | 500: 'INTERNAL_SERVER_ERROR', 29 | 401: 'UNAUTHORIZED', 30 | 403: 'FORBIDDEN', 31 | 408: 'TIMEOUT', 32 | 409: 'CONFLICT', 33 | 499: 'CLIENT_CLOSED_REQUEST', 34 | 412: 'PRECONDITION_FAILED', 35 | 413: 'PAYLOAD_TOO_LARGE', 36 | 405: 'METHOD_NOT_SUPPORTED', 37 | 415: 'UNSUPPORTED_MEDIA_TYPE', 38 | 429: 'TOO_MANY_REQUESTS', 39 | 422: 'UNPROCESSABLE_CONTENT', 40 | 501: 'NOT_IMPLEMENTED', 41 | 502: 'BAD_GATEWAY', 42 | 503: 'SERVICE_UNAVAILABLE', 43 | 504: 'GATEWAY_TIMEOUT', 44 | }; 45 | 46 | export const TRPC_ERROR_CODE_MESSAGE: Record = { 47 | PARSE_ERROR: 'Parse error', 48 | BAD_REQUEST: 'Bad request', 49 | NOT_FOUND: 'Not found', 50 | INTERNAL_SERVER_ERROR: 'Internal server error', 51 | UNAUTHORIZED: 'Unauthorized', 52 | FORBIDDEN: 'Forbidden', 53 | TIMEOUT: 'Timeout', 54 | CONFLICT: 'Conflict', 55 | CLIENT_CLOSED_REQUEST: 'Client closed request', 56 | PRECONDITION_FAILED: 'Precondition failed', 57 | PAYLOAD_TOO_LARGE: 'Payload too large', 58 | METHOD_NOT_SUPPORTED: 'Method not supported', 59 | TOO_MANY_REQUESTS: 'Too many requests', 60 | UNPROCESSABLE_CONTENT: 'Unprocessable content', 61 | NOT_IMPLEMENTED: 'Not implemented', 62 | BAD_GATEWAY: 'Bad gateway', 63 | SERVICE_UNAVAILABLE: 'Service unavailable', 64 | GATEWAY_TIMEOUT: 'Gateway timeout', 65 | UNSUPPORTED_MEDIA_TYPE: 'Unsupported media type', 66 | }; 67 | 68 | export function getErrorFromUnknown(cause: unknown): TRPCError { 69 | if (cause instanceof Error && cause.name === 'TRPCError') { 70 | return cause as TRPCError; 71 | } 72 | 73 | let errorCause: Error | undefined = undefined; 74 | let stack: string | undefined = undefined; 75 | 76 | if (cause instanceof Error) { 77 | errorCause = cause; 78 | stack = cause.stack; 79 | } 80 | 81 | const error = new TRPCError({ 82 | message: 'Internal server error', 83 | code: 'INTERNAL_SERVER_ERROR', 84 | cause: errorCause, 85 | }); 86 | 87 | if (stack) { 88 | error.stack = stack; 89 | } 90 | 91 | return error; 92 | } 93 | -------------------------------------------------------------------------------- /src/adapters/node-http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core'; 2 | export * from './errors'; 3 | export * from './input'; 4 | export * from './procedures'; 5 | -------------------------------------------------------------------------------- /src/adapters/node-http/input.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import parse from 'co-body'; 3 | import { NodeHTTPRequest } from '../../types'; 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 | const reqQuery = req.query as typeof query; 20 | 21 | // normalize first value in array 22 | Object.keys(reqQuery).forEach((key) => { 23 | const value = reqQuery[key]; 24 | if (value) { 25 | query[key] = Array.isArray(value) && value.length === 1 ? value[0]! : value; 26 | } 27 | }); 28 | 29 | return query; 30 | }; 31 | 32 | const BODY_100_KB = 100_000; 33 | export const getBody = async (req: NodeHTTPRequest, maxBodySize = BODY_100_KB): Promise => { 34 | if ('body' in req) { 35 | if (req.body instanceof ReadableStream) { 36 | return new Response(req.body as ReadableStream).json(); 37 | } 38 | return req.body; 39 | } 40 | 41 | req.body = undefined; 42 | 43 | const contentType = req.headers['content-type']; 44 | if (contentType === 'application/json' || contentType === 'application/x-www-form-urlencoded') { 45 | try { 46 | const { raw, parsed } = await parse(req, { 47 | limit: maxBodySize, 48 | strict: false, 49 | returnRawBody: true, 50 | }); 51 | req.body = raw ? parsed : undefined; 52 | } catch (cause) { 53 | if (cause instanceof Error && cause.name === 'PayloadTooLargeError') { 54 | throw new TRPCError({ 55 | message: 'Request body too large', 56 | code: 'PAYLOAD_TOO_LARGE', 57 | cause: cause, 58 | }); 59 | } 60 | 61 | let errorCause: Error | undefined = undefined; 62 | if (cause instanceof Error) { 63 | errorCause = cause; 64 | } 65 | 66 | throw new TRPCError({ 67 | message: 'Failed to parse request body', 68 | code: 'PARSE_ERROR', 69 | cause: errorCause, 70 | }); 71 | } 72 | } 73 | 74 | return req.body; 75 | }; 76 | -------------------------------------------------------------------------------- /src/adapters/node-http/procedures.ts: -------------------------------------------------------------------------------- 1 | import { OpenApiMethod, OpenApiProcedure, OpenApiRouter } from '../../types'; 2 | import { getPathRegExp, normalizePath, forEachOpenApiProcedure } from '../../utils'; 3 | 4 | export const createProcedureCache = (router: OpenApiRouter) => { 5 | const procedureCache = new Map< 6 | OpenApiMethod | 'HEAD', 7 | Map< 8 | RegExp, 9 | { 10 | type: 'query' | 'mutation'; 11 | path: string; 12 | procedure: OpenApiProcedure; 13 | } 14 | > 15 | >(); 16 | 17 | forEachOpenApiProcedure( 18 | router._def.procedures, 19 | ({ path: queryPath, procedure, meta: { openapi } }) => { 20 | if (procedure._def.type === 'subscription') { 21 | return; 22 | } 23 | const { method } = openapi; 24 | if (!procedureCache.has(method)) { 25 | procedureCache.set(method, new Map()); 26 | } 27 | const path = normalizePath(openapi.path); 28 | const pathRegExp = getPathRegExp(path); 29 | procedureCache.get(method)?.set(pathRegExp, { 30 | type: procedure._def.type, 31 | path: queryPath, 32 | procedure, 33 | }); 34 | }, 35 | ); 36 | 37 | return (method: OpenApiMethod | 'HEAD', path: string) => { 38 | const procedureMethodCache = procedureCache.get(method); 39 | if (!procedureMethodCache) { 40 | return undefined; 41 | } 42 | 43 | const procedureRegExp = Array.from(procedureMethodCache.keys()).find((re) => re.test(path)); 44 | if (!procedureRegExp) { 45 | return undefined; 46 | } 47 | 48 | const procedure = procedureMethodCache.get(procedureRegExp); 49 | const pathInput = procedureRegExp.exec(path)?.groups ?? {}; 50 | 51 | return { procedure, pathInput }; 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /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'; 8 | import { CreateOpenApiNodeHttpHandlerOptions, createOpenApiNodeHttpHandler } from './node-http'; 9 | 10 | export type CreateOpenApiNuxtHandlerOptions = Omit< 11 | CreateOpenApiNodeHttpHandlerOptions, 12 | 'maxBodySize' 13 | >; 14 | 15 | type NuxtRequest = IncomingMessage & { 16 | query?: ReturnType; 17 | }; 18 | 19 | export const createOpenApiNuxtHandler = ( 20 | opts: CreateOpenApiNuxtHandlerOptions, 21 | ) => { 22 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 23 | 24 | return defineEventHandler(async (event) => { 25 | let pathname: string | null = null; 26 | 27 | const params = event.context.params; 28 | if (params?.trpc) { 29 | if (!params.trpc.includes('/')) { 30 | pathname = params.trpc; 31 | } else { 32 | pathname = params.trpc; 33 | } 34 | } 35 | 36 | if (pathname === null) { 37 | const error = new TRPCError({ 38 | message: 'Query "trpc" not found - is the `trpc-to-openapi` file named `[...trpc].ts`?', 39 | code: 'INTERNAL_SERVER_ERROR', 40 | }); 41 | 42 | opts.onError?.({ 43 | error, 44 | type: 'unknown', 45 | path: undefined, 46 | input: undefined, 47 | ctx: undefined, 48 | req: event.node.req, 49 | }); 50 | 51 | event.node.res.statusCode = 500; 52 | event.node.res.setHeader('Content-Type', 'application/json'); 53 | const body: OpenApiErrorResponse = { 54 | message: error.message, 55 | code: error.code, 56 | }; 57 | event.node.res.end(JSON.stringify(body)); 58 | 59 | return; 60 | } 61 | 62 | (event.node.req as NuxtRequest).query = getQuery(event); 63 | event.node.req.url = normalizePath(pathname); 64 | await openApiHttpHandler(event.node.req, event.node.res); 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/adapters/standalone.ts: -------------------------------------------------------------------------------- 1 | import { incomingMessageToRequest } from '@trpc/server/adapters/node-http'; 2 | import { IncomingMessage, ServerResponse } from 'http'; 3 | 4 | import { OpenApiRouter } from '../types'; 5 | import { 6 | CreateOpenApiNodeHttpHandlerOptions, 7 | createOpenApiNodeHttpHandler, 8 | } from './node-http/core'; 9 | 10 | export type CreateOpenApiHttpHandlerOptions = 11 | CreateOpenApiNodeHttpHandlerOptions; 12 | 13 | export const createOpenApiHttpHandler = ( 14 | opts: CreateOpenApiHttpHandlerOptions, 15 | ) => { 16 | const openApiHttpHandler = createOpenApiNodeHttpHandler(opts); 17 | return async (req: IncomingMessage, res: ServerResponse) => { 18 | await openApiHttpHandler( 19 | incomingMessageToRequest(req, res, { 20 | maxBodySize: opts.maxBodySize ?? null, 21 | }) as unknown as IncomingMessage, 22 | res, 23 | ); 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/generator/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiObject, ZodOpenApiPathsObject, createDocument } from 'zod-openapi'; 2 | 3 | import { 4 | OpenApiMeta, 5 | type OpenAPIObject, 6 | OpenApiRouter, 7 | type SecuritySchemeObject, 8 | } from '../types'; 9 | import { getOpenApiPathsObject, mergePaths } from './paths'; 10 | 11 | export interface GenerateOpenApiDocumentOptions> { 12 | title: string; 13 | description?: string; 14 | version: string; 15 | openApiVersion?: ZodOpenApiObject['openapi']; 16 | baseUrl: string; 17 | docsUrl?: string; 18 | tags?: string[]; 19 | securitySchemes?: Record; 20 | paths?: ZodOpenApiPathsObject; 21 | /** 22 | * Optional filter function to include/exclude procedures from the generated OpenAPI document. 23 | * 24 | * The function receives a context object with the procedure's metadata as `ctx.metadata`. 25 | * Return `true` to include the procedure, or `false` to exclude it from the OpenAPI output. 26 | * 27 | * @example 28 | * filter: ({ metadata }) => metadata.isPublic === true 29 | */ 30 | filter?: (ctx: { metadata: { openapi: NonNullable } & TMeta }) => boolean; 31 | } 32 | 33 | export const generateOpenApiDocument = >( 34 | appRouter: OpenApiRouter, 35 | opts: GenerateOpenApiDocumentOptions, 36 | ): OpenAPIObject => { 37 | const securitySchemes = opts.securitySchemes ?? { 38 | Authorization: { 39 | type: 'http', 40 | scheme: 'bearer', 41 | }, 42 | }; 43 | return createDocument({ 44 | openapi: opts.openApiVersion ?? '3.0.3', 45 | info: { 46 | title: opts.title, 47 | description: opts.description, 48 | version: opts.version, 49 | }, 50 | servers: [ 51 | { 52 | url: opts.baseUrl, 53 | }, 54 | ], 55 | paths: mergePaths( 56 | getOpenApiPathsObject(appRouter, Object.keys(securitySchemes), opts.filter), 57 | opts.paths, 58 | ), 59 | components: { 60 | securitySchemes, 61 | }, 62 | tags: opts.tags?.map((tag) => ({ name: tag })), 63 | externalDocs: opts.docsUrl ? { url: opts.docsUrl } : undefined, 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /src/generator/paths.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { z } from 'zod'; 3 | import { 4 | ZodOpenApiParameters, 5 | ZodOpenApiPathsObject, 6 | ZodOpenApiRequestBodyObject, 7 | extendZodWithOpenApi, 8 | } from 'zod-openapi'; 9 | 10 | import { OpenApiMeta, OpenApiRouter } from '../types'; 11 | import { 12 | acceptsRequestBody, 13 | getPathParameters, 14 | normalizePath, 15 | forEachOpenApiProcedure, 16 | getInputOutputParsers, 17 | instanceofZodType, 18 | instanceofZodTypeLikeVoid, 19 | instanceofZodTypeObject, 20 | unwrapZodType, 21 | } from '../utils'; 22 | import { getParameterObjects, getRequestBodyObject, getResponsesObject, hasInputs } from './schema'; 23 | 24 | extendZodWithOpenApi(z); 25 | 26 | export enum HttpMethods { 27 | GET = 'get', 28 | POST = 'post', 29 | PATCH = 'patch', 30 | PUT = 'put', 31 | DELETE = 'delete', 32 | } 33 | 34 | export const getOpenApiPathsObject = >( 35 | appRouter: OpenApiRouter, 36 | securitySchemeNames: string[], 37 | filter?: (ctx: { 38 | metadata: { 39 | openapi: NonNullable; 40 | } & TMeta; 41 | }) => boolean, 42 | ): ZodOpenApiPathsObject => { 43 | const pathsObject: ZodOpenApiPathsObject = {}; 44 | const procedures = Object.assign({}, appRouter._def.procedures); 45 | 46 | forEachOpenApiProcedure(procedures, ({ path: procedurePath, type, procedure, meta }) => { 47 | if (typeof filter === 'function' && !filter({ metadata: meta })) { 48 | return; 49 | } 50 | 51 | const procedureName = `${type}.${procedurePath}`; 52 | 53 | try { 54 | if (type === 'subscription') { 55 | throw new TRPCError({ 56 | message: 'Subscriptions are not supported by OpenAPI v3', 57 | code: 'INTERNAL_SERVER_ERROR', 58 | }); 59 | } 60 | 61 | const { openapi } = meta; 62 | const { 63 | method, 64 | summary, 65 | description, 66 | tags, 67 | requestHeaders, 68 | responseHeaders, 69 | successDescription, 70 | errorResponses, 71 | protect = true, 72 | } = meta.openapi; 73 | 74 | const path = normalizePath(openapi.path); 75 | const pathParameters = getPathParameters(path); 76 | 77 | const httpMethod = HttpMethods[method]; 78 | if (!httpMethod) { 79 | throw new TRPCError({ 80 | message: 'Method must be GET, POST, PATCH, PUT or DELETE', 81 | code: 'INTERNAL_SERVER_ERROR', 82 | }); 83 | } 84 | 85 | if (pathsObject[path]?.[httpMethod]) { 86 | throw new TRPCError({ 87 | message: `Duplicate procedure defined for route ${method} ${path}`, 88 | code: 'INTERNAL_SERVER_ERROR', 89 | }); 90 | } 91 | 92 | const contentTypes = openapi.contentTypes ?? ['application/json']; 93 | if (contentTypes.length === 0) { 94 | throw new TRPCError({ 95 | message: 'At least one content type must be specified', 96 | code: 'INTERNAL_SERVER_ERROR', 97 | }); 98 | } 99 | 100 | const { inputParser, outputParser } = getInputOutputParsers(procedure); 101 | 102 | if (!instanceofZodType(inputParser)) { 103 | throw new TRPCError({ 104 | message: 'Input parser expects a Zod validator', 105 | code: 'INTERNAL_SERVER_ERROR', 106 | }); 107 | } 108 | if (!instanceofZodType(outputParser)) { 109 | throw new TRPCError({ 110 | message: 'Output parser expects a Zod validator', 111 | code: 'INTERNAL_SERVER_ERROR', 112 | }); 113 | } 114 | const isInputRequired = !inputParser.isOptional(); 115 | const o = inputParser?._def.zodOpenApi?.openapi; 116 | const inputSchema = unwrapZodType(inputParser, true).openapi({ 117 | ...(o?.title ? { title: o?.title } : {}), 118 | ...(o?.description ? { description: o?.description } : {}), 119 | }); 120 | 121 | const requestData: { 122 | requestBody?: ZodOpenApiRequestBodyObject; 123 | requestParams?: ZodOpenApiParameters; 124 | } = {}; 125 | if (!(pathParameters.length === 0 && instanceofZodTypeLikeVoid(inputSchema))) { 126 | if (!instanceofZodTypeObject(inputSchema)) { 127 | throw new TRPCError({ 128 | message: 'Input parser must be a ZodObject', 129 | code: 'INTERNAL_SERVER_ERROR', 130 | }); 131 | } 132 | 133 | if (acceptsRequestBody(method)) { 134 | requestData.requestBody = getRequestBodyObject( 135 | inputSchema, 136 | isInputRequired, 137 | pathParameters, 138 | contentTypes, 139 | ); 140 | requestData.requestParams = 141 | getParameterObjects( 142 | inputSchema, 143 | isInputRequired, 144 | pathParameters, 145 | requestHeaders, 146 | 'path', 147 | ) ?? {}; 148 | } else { 149 | requestData.requestParams = 150 | getParameterObjects( 151 | inputSchema, 152 | isInputRequired, 153 | pathParameters, 154 | requestHeaders, 155 | 'all', 156 | ) ?? {}; 157 | } 158 | } 159 | 160 | const responses = getResponsesObject( 161 | outputParser, 162 | httpMethod, 163 | responseHeaders, 164 | protect, 165 | hasInputs(inputParser), 166 | successDescription, 167 | errorResponses, 168 | ); 169 | 170 | const security = protect ? securitySchemeNames.map((name) => ({ [name]: [] })) : undefined; 171 | 172 | pathsObject[path] = { 173 | ...pathsObject[path], 174 | [httpMethod]: { 175 | operationId: procedurePath.replace(/\./g, '-'), 176 | summary, 177 | description, 178 | tags, 179 | security, 180 | ...requestData, 181 | responses, 182 | ...(openapi.deprecated ? { deprecated: openapi.deprecated } : {}), 183 | }, 184 | }; 185 | } catch (error: unknown) { 186 | if (error instanceof TRPCError) { 187 | error.message = `[${procedureName}] - ${error.message}`; 188 | } 189 | throw error; 190 | } 191 | }); 192 | 193 | return pathsObject; 194 | }; 195 | 196 | export const mergePaths = (x?: ZodOpenApiPathsObject, y?: ZodOpenApiPathsObject) => { 197 | if (x === undefined) return y; 198 | if (y === undefined) return x; 199 | const obj: ZodOpenApiPathsObject = x; 200 | for (const [k, v] of Object.entries(y)) { 201 | if (k in obj) obj[k] = { ...obj[k], ...v }; 202 | else obj[k] = v; 203 | } 204 | return obj; 205 | }; 206 | -------------------------------------------------------------------------------- /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 { 13 | HTTP_STATUS_TRPC_ERROR_CODE, 14 | TRPC_ERROR_CODE_HTTP_STATUS, 15 | TRPC_ERROR_CODE_MESSAGE, 16 | } from '../adapters'; 17 | import { OpenApiContentType } from '../types'; 18 | import { 19 | instanceofZodType, 20 | instanceofZodTypeCoercible, 21 | instanceofZodTypeKind, 22 | instanceofZodTypeLikeString, 23 | instanceofZodTypeLikeVoid, 24 | instanceofZodTypeOptional, 25 | unwrapZodType, 26 | zodSupportsCoerce, 27 | } from '../utils'; 28 | import { HttpMethods } from './paths'; 29 | 30 | extendZodWithOpenApi(z); 31 | 32 | export const getParameterObjects = ( 33 | schema: z.ZodObject, 34 | required: boolean, 35 | pathParameters: string[], 36 | headersSchema: AnyZodObject | undefined, 37 | inType: 'all' | 'path' | 'query', 38 | ): ZodOpenApiParameters | undefined => { 39 | const shape = schema.shape; 40 | const shapeKeys = Object.keys(shape); 41 | 42 | for (const pathParameter of pathParameters) { 43 | if (!shapeKeys.includes(pathParameter)) { 44 | throw new TRPCError({ 45 | message: `Input parser expects key from path: "${pathParameter}"`, 46 | code: 'INTERNAL_SERVER_ERROR', 47 | }); 48 | } 49 | } 50 | 51 | const { path, query } = shapeKeys 52 | .filter((shapeKey) => { 53 | const isPathParameter = pathParameters.includes(shapeKey); 54 | if (inType === 'path') { 55 | return isPathParameter; 56 | } else if (inType === 'query') { 57 | return !isPathParameter; 58 | } 59 | return true; 60 | }) 61 | .map((shapeKey) => { 62 | let shapeSchema = shape[shapeKey]!; 63 | const isShapeRequired = !shapeSchema.isOptional(); 64 | const isPathParameter = pathParameters.includes(shapeKey); 65 | 66 | if (!instanceofZodTypeLikeString(shapeSchema)) { 67 | if (zodSupportsCoerce) { 68 | if (!instanceofZodTypeCoercible(shapeSchema)) { 69 | throw new TRPCError({ 70 | message: `Input parser key: "${shapeKey}" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate`, 71 | code: 'INTERNAL_SERVER_ERROR', 72 | }); 73 | } 74 | } else { 75 | throw new TRPCError({ 76 | message: `Input parser key: "${shapeKey}" must be ZodString`, 77 | code: 'INTERNAL_SERVER_ERROR', 78 | }); 79 | } 80 | } 81 | 82 | if (instanceofZodTypeOptional(shapeSchema)) { 83 | if (isPathParameter) { 84 | throw new TRPCError({ 85 | message: `Path parameter: "${shapeKey}" must not be optional`, 86 | code: 'INTERNAL_SERVER_ERROR', 87 | }); 88 | } 89 | shapeSchema = shapeSchema.unwrap(); 90 | } 91 | 92 | return { 93 | name: shapeKey, 94 | paramType: isPathParameter ? 'path' : 'query', 95 | required: isPathParameter || (required && isShapeRequired), 96 | schema: shapeSchema, 97 | }; 98 | }) 99 | .reduce( 100 | ({ path, query }, { name, paramType, schema, required }) => 101 | paramType === 'path' 102 | ? { path: { ...path, [name]: required ? schema : schema.optional() }, query } 103 | : { path, query: { ...query, [name]: required ? schema : schema.optional() } }, 104 | { path: {} as Record, query: {} as Record }, 105 | ); 106 | 107 | return { header: headersSchema, path: z.object(path), query: z.object(query) }; 108 | }; 109 | 110 | export const getRequestBodyObject = ( 111 | schema: z.ZodObject, 112 | required: boolean, 113 | pathParameters: string[], 114 | contentTypes: OpenApiContentType[], 115 | ): ZodOpenApiRequestBodyObject | undefined => { 116 | // remove path parameters 117 | const mask: Record = {}; 118 | pathParameters.forEach((pathParameter) => { 119 | mask[pathParameter] = true; 120 | }); 121 | const o = schema._def.zodOpenApi?.openapi; 122 | const dedupedSchema = schema.omit(mask).openapi({ 123 | ...(o?.title ? { title: o?.title } : {}), 124 | ...(o?.description ? { description: o?.description } : {}), 125 | }); 126 | 127 | // if all keys are path parameters 128 | if (pathParameters.length > 0 && Object.keys(dedupedSchema.shape).length === 0) { 129 | return undefined; 130 | } 131 | 132 | const content: ZodOpenApiContentObject = {}; 133 | for (const contentType of contentTypes) { 134 | content[contentType] = { 135 | schema: dedupedSchema, 136 | }; 137 | } 138 | return { 139 | required, 140 | content, 141 | }; 142 | }; 143 | 144 | export const hasInputs = (schema: unknown) => 145 | instanceofZodType(schema) && !instanceofZodTypeLikeVoid(unwrapZodType(schema, true)); 146 | 147 | const errorResponseObjectByCode: Record = {}; 148 | 149 | export const errorResponseObject = ( 150 | code: TRPCError['code'] = 'INTERNAL_SERVER_ERROR', 151 | message?: string, 152 | issues?: { message: string }[], 153 | ): ZodOpenApiResponseObject => { 154 | if (!errorResponseObjectByCode[code]) { 155 | errorResponseObjectByCode[code] = { 156 | description: message ?? 'An error response', 157 | content: { 158 | 'application/json': { 159 | schema: z 160 | .object({ 161 | message: z.string().openapi({ 162 | description: 'The error message', 163 | example: message ?? 'Internal server error', 164 | }), 165 | code: z.string().openapi({ 166 | description: 'The error code', 167 | example: code ?? 'INTERNAL_SERVER_ERROR', 168 | }), 169 | issues: z 170 | .array(z.object({ message: z.string() })) 171 | .optional() 172 | .openapi({ 173 | description: 'An array of issues that were responsible for the error', 174 | example: issues ?? [], 175 | }), 176 | }) 177 | .openapi({ 178 | title: `${message ?? 'Internal server'} error (${ 179 | TRPC_ERROR_CODE_HTTP_STATUS[code] ?? 500 180 | })`, 181 | description: 'The error information', 182 | example: { 183 | code: code ?? 'INTERNAL_SERVER_ERROR', 184 | message: message ?? 'Internal server error', 185 | issues: issues ?? [], 186 | }, 187 | ref: `error.${code}`, 188 | }), 189 | }, 190 | }, 191 | }; 192 | } 193 | return errorResponseObjectByCode[code]; 194 | }; 195 | 196 | export const errorResponseFromStatusCode = (status: number) => { 197 | const code = HTTP_STATUS_TRPC_ERROR_CODE[status]; 198 | const message = code && TRPC_ERROR_CODE_MESSAGE[code]; 199 | return errorResponseObject(code, message ?? 'Unknown error'); 200 | }; 201 | 202 | export const errorResponseFromMessage = (status: number, message: string) => 203 | errorResponseObject(HTTP_STATUS_TRPC_ERROR_CODE[status], message); 204 | 205 | export const getResponsesObject = ( 206 | schema: ZodTypeAny, 207 | httpMethod: HttpMethods, 208 | headers: AnyZodObject | undefined, 209 | isProtected: boolean, 210 | hasInputs: boolean, 211 | successDescription?: string, 212 | errorResponses?: number[] | Record, 213 | ): ZodOpenApiResponsesObject => ({ 214 | 200: { 215 | description: successDescription ?? 'Successful response', 216 | headers: headers, 217 | content: { 218 | 'application/json': { 219 | schema: instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodVoid) 220 | ? {} 221 | : instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodNever) || 222 | instanceofZodTypeKind(schema, z.ZodFirstPartyTypeKind.ZodUndefined) 223 | ? { not: {} } 224 | : schema, 225 | }, 226 | }, 227 | }, 228 | ...(errorResponses !== undefined 229 | ? Object.fromEntries( 230 | Array.isArray(errorResponses) 231 | ? errorResponses.map((x) => [x, errorResponseFromStatusCode(x)]) 232 | : Object.entries(errorResponses).map(([k, v]) => [ 233 | k, 234 | errorResponseFromMessage(Number(k), v), 235 | ]), 236 | ) 237 | : { 238 | ...(isProtected 239 | ? { 240 | 401: errorResponseObject('UNAUTHORIZED', 'Authorization not provided'), 241 | 403: errorResponseObject('FORBIDDEN', 'Insufficient access'), 242 | } 243 | : {}), 244 | ...(hasInputs 245 | ? { 246 | 400: errorResponseObject('BAD_REQUEST', 'Invalid input data'), 247 | ...(httpMethod !== HttpMethods.POST 248 | ? { 249 | 404: errorResponseObject('NOT_FOUND', 'Not found'), 250 | } 251 | : {}), 252 | } 253 | : {}), 254 | 500: errorResponseObject('INTERNAL_SERVER_ERROR', 'Internal server error'), 255 | }), 256 | }); 257 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { OpenApiBuilder } from 'openapi3-ts/oas31'; 2 | 3 | export * from './utils'; 4 | export * from './adapters'; 5 | export * from './types'; 6 | export * from './generator'; 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc'; 2 | import type { 3 | CreateRootTypes, 4 | Procedure, 5 | ProcedureType, 6 | Router, 7 | RouterRecord, 8 | } from '@trpc/server/unstable-core-do-not-import'; 9 | import { IncomingMessage } from 'http'; 10 | import type { AnyZodObject, ZodIssue } from 'zod'; 11 | 12 | export { type OpenAPIObject, type SecuritySchemeObject } from 'openapi3-ts/oas31'; 13 | 14 | export type OpenApiMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; 15 | 16 | type TRPCMeta = Record; 17 | 18 | export type OpenApiContentType = 19 | | 'application/json' 20 | | 'application/x-www-form-urlencoded' 21 | // eslint-disable-next-line @typescript-eslint/ban-types 22 | | (string & {}); 23 | 24 | export type OpenApiMeta = TMeta & { 25 | openapi?: { 26 | enabled?: boolean; 27 | method: OpenApiMethod; 28 | path: `/${string}`; 29 | summary?: string; 30 | description?: string; 31 | protect?: boolean; 32 | tags?: string[]; 33 | contentTypes?: OpenApiContentType[]; 34 | deprecated?: boolean; 35 | requestHeaders?: AnyZodObject; 36 | responseHeaders?: AnyZodObject; 37 | successDescription?: string; 38 | errorResponses?: number[] | Record; 39 | }; 40 | }; 41 | 42 | export type OpenApiProcedure = Procedure< 43 | ProcedureType, 44 | { 45 | input: any; // AnyZodObject[] | Parser[] | undefined; 46 | output: any; // Parser | undefined; 47 | } 48 | >; 49 | 50 | export type OpenApiProcedureRecord = Record; 51 | 52 | export type OpenApiRouter = Router< 53 | CreateRootTypes<{ 54 | ctx: any; 55 | meta: TRPCMeta; 56 | errorShape: any; 57 | transformer: any; 58 | }>, 59 | RouterRecord 60 | >; 61 | 62 | export type OpenApiSuccessResponse = D; 63 | 64 | export interface OpenApiErrorResponse { 65 | message: string; 66 | code: TRPC_ERROR_CODE_KEY; 67 | issues?: ZodIssue[]; 68 | } 69 | 70 | export type OpenApiResponse = OpenApiSuccessResponse | OpenApiErrorResponse; 71 | 72 | export type NodeHTTPRequest = IncomingMessage & { 73 | body?: unknown; 74 | query?: unknown; 75 | }; 76 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './method'; 2 | export * from './path'; 3 | export * from './procedure'; 4 | export * from './zod'; 5 | -------------------------------------------------------------------------------- /src/utils/method.ts: -------------------------------------------------------------------------------- 1 | import { incomingMessageToRequest, NodeHTTPResponse } from '@trpc/server/adapters/node-http'; 2 | import { NodeHTTPRequest, OpenApiMethod } from '../types'; 3 | 4 | export const acceptsRequestBody = (method: OpenApiMethod | 'HEAD') => { 5 | if (method === 'GET' || method === 'DELETE') { 6 | return false; 7 | } 8 | return true; 9 | }; 10 | 11 | export const getContentType = (req: NodeHTTPRequest | Request): string | undefined => { 12 | if (req instanceof Request) { 13 | return req.headers.get('content-type') ?? undefined; 14 | } 15 | 16 | return req.headers['content-type'] ?? undefined; 17 | }; 18 | 19 | export const getRequestSignal = ( 20 | req: NodeHTTPRequest | Request, 21 | res: NodeHTTPResponse, 22 | maxBodySize?: number, 23 | ) => { 24 | if (req instanceof Request) { 25 | return req.signal; 26 | } 27 | 28 | return incomingMessageToRequest(req, res, { 29 | maxBodySize: maxBodySize ?? null, 30 | }).signal; 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | export const normalizePath = (path: string) => { 2 | return `/${path.replace(/^\/|\/$/g, '')}`; 3 | }; 4 | 5 | export const getPathParameters = (path: string): 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 { TRPCProcedureType } from '@trpc/server'; 2 | import { AnyZodObject, z } from 'zod'; 3 | 4 | import { OpenApiMeta, OpenApiProcedure, OpenApiProcedureRecord } from '../types'; 5 | 6 | const mergeInputs = (inputParsers: AnyZodObject[]): AnyZodObject => { 7 | return inputParsers.reduce((acc, inputParser) => { 8 | return acc.merge(inputParser); 9 | }, z.object({})); 10 | }; 11 | 12 | // `inputParser` & `outputParser` are private so this is a hack to access it 13 | export const getInputOutputParsers = ( 14 | procedure: OpenApiProcedure, 15 | ): { 16 | inputParser: AnyZodObject | undefined; 17 | outputParser: AnyZodObject | undefined; 18 | } => { 19 | // @ts-expect-error The types seems to be incorrect 20 | const inputs = procedure._def.inputs as AnyZodObject[]; 21 | // @ts-expect-error The types seems to be incorrect 22 | const output = procedure._def.output as AnyZodObject; 23 | 24 | return { 25 | inputParser: inputs.length >= 2 ? mergeInputs(inputs) : inputs[0], 26 | outputParser: output, 27 | }; 28 | }; 29 | 30 | const getProcedureType = (procedure: OpenApiProcedure): TRPCProcedureType => { 31 | if (!procedure._def.type) { 32 | throw new Error('Unknown procedure type'); 33 | } 34 | return procedure._def.type; 35 | }; 36 | 37 | export const forEachOpenApiProcedure = >( 38 | procedureRecord: OpenApiProcedureRecord, 39 | callback: (values: { 40 | path: string; 41 | type: TRPCProcedureType; 42 | procedure: OpenApiProcedure; 43 | meta: { 44 | openapi: NonNullable; 45 | } & TMeta; 46 | }) => void, 47 | ) => { 48 | for (const [path, procedure] of Object.entries(procedureRecord)) { 49 | // @ts-expect-error FIXME 50 | const meta = procedure._def.meta as unknown as OpenApiMeta | undefined; 51 | if (meta?.openapi && meta.openapi.enabled !== false) { 52 | const type = getProcedureType(procedure as OpenApiProcedure); 53 | callback({ 54 | path, 55 | type, 56 | procedure: procedure as OpenApiProcedure, 57 | meta: { 58 | openapi: meta.openapi, 59 | ...(meta as TMeta), 60 | }, 61 | }); 62 | } 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import { ZodObject, ZodRawShape, 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 | interface 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/express.test.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import express from 'express'; 3 | import fetch from 'node-fetch'; 4 | import { z } from 'zod'; 5 | 6 | import { 7 | CreateOpenApiExpressMiddlewareOptions, 8 | OpenApiMeta, 9 | OpenApiRouter, 10 | createOpenApiExpressMiddleware, 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 createExpressServerWithRouter = ( 24 | handlerOpts: CreateOpenApiExpressMiddlewareOptions, 25 | serverOpts?: { basePath?: `/${string}` }, 26 | ) => { 27 | const openApiExpressMiddleware = createOpenApiExpressMiddleware({ 28 | router: handlerOpts.router, 29 | createContext: handlerOpts.createContext ?? createContextMock, 30 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 31 | onError: handlerOpts.onError ?? onErrorMock, 32 | maxBodySize: handlerOpts.maxBodySize, 33 | } as any); 34 | 35 | const app = express(); 36 | 37 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 38 | app.use(serverOpts?.basePath ?? '/', openApiExpressMiddleware); 39 | 40 | const server = app.listen(0); 41 | const port = (server.address() as any).port as number; 42 | const url = `http://localhost:${port}`; 43 | 44 | return { 45 | url, 46 | close: () => server.close(), 47 | }; 48 | }; 49 | 50 | const t = initTRPC.meta().context().create(); 51 | 52 | describe('express adapter', () => { 53 | afterEach(() => { 54 | clearMocks(); 55 | }); 56 | 57 | test('with valid routes', async () => { 58 | const appRouter = t.router({ 59 | sayHelloQuery: t.procedure 60 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 61 | .input(z.object({ name: z.string() })) 62 | .output(z.object({ greeting: z.string() })) 63 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 64 | sayHelloMutation: t.procedure 65 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 66 | .input(z.object({ name: z.string() })) 67 | .output(z.object({ greeting: z.string() })) 68 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 69 | sayHelloSlash: t.procedure 70 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 71 | .input(z.object({ name: z.string() })) 72 | .output(z.object({ greeting: z.string() })) 73 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 74 | }); 75 | 76 | const { url, close } = createExpressServerWithRouter({ 77 | router: appRouter, 78 | }); 79 | 80 | { 81 | const res = await fetch(`${url}/say-hello?name=Lily`, { method: 'GET' }); 82 | 83 | expect(res.status).toBe(200); 84 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 85 | expect(createContextMock).toHaveBeenCalledTimes(1); 86 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 87 | expect(onErrorMock).toHaveBeenCalledTimes(0); 88 | 89 | clearMocks(); 90 | } 91 | { 92 | const res = await fetch(`${url}/say-hello`, { 93 | method: 'POST', 94 | headers: { 'Content-Type': 'application/json' }, 95 | body: JSON.stringify({ name: 'Lily' }), 96 | }); 97 | 98 | expect(res.status).toBe(200); 99 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 100 | expect(createContextMock).toHaveBeenCalledTimes(1); 101 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 102 | expect(onErrorMock).toHaveBeenCalledTimes(0); 103 | 104 | clearMocks(); 105 | } 106 | { 107 | const res = await fetch(`${url}/say/hello?name=Lily`, { method: 'GET' }); 108 | 109 | expect(res.status).toBe(200); 110 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 111 | expect(createContextMock).toHaveBeenCalledTimes(1); 112 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 113 | expect(onErrorMock).toHaveBeenCalledTimes(0); 114 | } 115 | 116 | close(); 117 | }); 118 | 119 | test('with basePath', async () => { 120 | const appRouter = t.router({ 121 | echo: t.procedure 122 | .meta({ openapi: { method: 'GET', path: '/echo' } }) 123 | .input(z.object({ payload: z.string() })) 124 | .output(z.object({ payload: z.string() })) 125 | .query(({ input }) => ({ payload: input.payload })), 126 | }); 127 | 128 | const { url, close } = createExpressServerWithRouter( 129 | { router: appRouter }, 130 | { basePath: '/open-api' }, 131 | ); 132 | 133 | const res = await fetch(`${url}/open-api/echo?payload=mcampa`, { method: 'GET' }); 134 | 135 | expect(res.status).toBe(200); 136 | expect(await res.json()).toEqual({ 137 | payload: 'mcampa', 138 | }); 139 | expect(createContextMock).toHaveBeenCalledTimes(1); 140 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 141 | expect(onErrorMock).toHaveBeenCalledTimes(0); 142 | 143 | close(); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /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=mcampa`, { method: 'GET' }); 142 | const body = await res.json(); 143 | 144 | expect(res.status).toBe(200); 145 | expect(body).toEqual({ 146 | payload: 'mcampa', 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=mcampa`, { 172 | method: 'GET', 173 | }); 174 | const body = await res.json(); 175 | 176 | expect(res.status).toBe(200); 177 | expect(body).toEqual({ 178 | payload: 'mcampa', 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/koa.test.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import Koa from 'koa'; 3 | import fetch from 'node-fetch'; 4 | import { z } from 'zod'; 5 | 6 | import { 7 | CreateOpenApiKoaMiddlewareOptions, 8 | OpenApiMeta, 9 | OpenApiRouter, 10 | createOpenApiKoaMiddleware, 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 createKoaServerWithRouter = ( 24 | handlerOpts: CreateOpenApiKoaMiddlewareOptions, 25 | ) => { 26 | const openApiKoaMiddleware = createOpenApiKoaMiddleware({ 27 | router: handlerOpts.router, 28 | createContext: handlerOpts.createContext ?? createContextMock, 29 | responseMeta: handlerOpts.responseMeta ?? (responseMetaMock as any), 30 | onError: handlerOpts.onError ?? (onErrorMock as any), 31 | maxBodySize: handlerOpts.maxBodySize, 32 | }); 33 | 34 | const app = new Koa(); 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 37 | app.use(openApiKoaMiddleware); 38 | 39 | const server = app.listen(0); 40 | const port = (server.address() as any).port as number; 41 | const url = `http://localhost:${port}`; 42 | 43 | return { 44 | url, 45 | close: () => server.close(), 46 | }; 47 | }; 48 | 49 | const t = initTRPC.meta().context().create(); 50 | 51 | describe('koa adapter', () => { 52 | afterEach(() => { 53 | clearMocks(); 54 | }); 55 | 56 | test('with valid routes', async () => { 57 | const appRouter = t.router({ 58 | sayHelloQuery: t.procedure 59 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 60 | .input(z.object({ name: z.string() })) 61 | .output(z.object({ greeting: z.string() })) 62 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 63 | sayHelloMutation: t.procedure 64 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 65 | .input(z.object({ name: z.string() })) 66 | .output(z.object({ greeting: z.string() })) 67 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 68 | sayHelloSlash: t.procedure 69 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 70 | .input(z.object({ name: z.string() })) 71 | .output(z.object({ greeting: z.string() })) 72 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 73 | }); 74 | 75 | const { url, close } = createKoaServerWithRouter({ 76 | router: appRouter, 77 | }); 78 | 79 | { 80 | const res = await fetch(`${url}/say-hello?name=Lily`, { method: 'GET' }); 81 | 82 | expect(res.status).toBe(200); 83 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 84 | expect(createContextMock).toHaveBeenCalledTimes(1); 85 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 86 | expect(onErrorMock).toHaveBeenCalledTimes(0); 87 | 88 | clearMocks(); 89 | } 90 | { 91 | const res = await fetch(`${url}/say-hello`, { 92 | method: 'POST', 93 | headers: { 'Content-Type': 'application/json' }, 94 | body: JSON.stringify({ name: 'Lily' }), 95 | }); 96 | 97 | expect(res.status).toBe(200); 98 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 99 | expect(createContextMock).toHaveBeenCalledTimes(1); 100 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 101 | expect(onErrorMock).toHaveBeenCalledTimes(0); 102 | 103 | clearMocks(); 104 | } 105 | { 106 | const res = await fetch(`${url}/say/hello?name=Lily`, { method: 'GET' }); 107 | 108 | expect(res.status).toBe(200); 109 | expect(await res.json()).toEqual({ greeting: 'Hello Lily!' }); 110 | expect(createContextMock).toHaveBeenCalledTimes(1); 111 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 112 | expect(onErrorMock).toHaveBeenCalledTimes(0); 113 | } 114 | 115 | close(); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/adapters/next.test.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { IncomingHttpHeaders, IncomingMessage } from 'http'; 4 | import { NextApiRequestCookies, NextApiRequestQuery } from 'next/dist/server/api-utils'; 5 | import { Socket } from 'net'; 6 | import { z } from 'zod'; 7 | 8 | import { 9 | CreateOpenApiNextHandlerOptions, 10 | OpenApiMeta, 11 | OpenApiResponse, 12 | OpenApiRouter, 13 | createOpenApiNextHandler, 14 | } from '../../src'; 15 | 16 | type NextApiRequestOptions = Partial; 17 | class NextApiRequestMock extends IncomingMessage implements NextApiRequest { 18 | public query: NextApiRequestQuery = {}; 19 | public cookies: NextApiRequestCookies = {}; 20 | public headers: IncomingHttpHeaders = {}; 21 | public env = {}; 22 | public body: unknown; 23 | 24 | constructor(options: NextApiRequestOptions) { 25 | super(new Socket()); 26 | 27 | this.method = options.method; 28 | this.body = options.body; 29 | this.query = options.query ?? {}; 30 | this.headers = options.headers ?? {}; 31 | this.env = options.env ?? {}; 32 | } 33 | } 34 | 35 | const createContextMock = jest.fn(); 36 | const responseMetaMock = jest.fn(); 37 | const onErrorMock = jest.fn(); 38 | 39 | const clearMocks = () => { 40 | createContextMock.mockClear(); 41 | responseMetaMock.mockClear(); 42 | onErrorMock.mockClear(); 43 | }; 44 | 45 | const createOpenApiNextHandlerCaller = ( 46 | handlerOpts: CreateOpenApiNextHandlerOptions, 47 | ) => { 48 | const openApiNextHandler = createOpenApiNextHandler({ 49 | router: handlerOpts.router, 50 | createContext: handlerOpts.createContext ?? createContextMock, 51 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 52 | onError: handlerOpts.onError ?? onErrorMock, 53 | } as any); 54 | 55 | return (req: { 56 | method: string; 57 | query: Record; 58 | body?: any; 59 | headers?: Record; 60 | }) => { 61 | return new Promise<{ 62 | statusCode: number; 63 | headers: Record; 64 | body: OpenApiResponse; 65 | }>(async (resolve, reject) => { 66 | const headers = new Headers(); 67 | let body: any; 68 | const nextResponse = { 69 | statusCode: undefined, 70 | setHeader: (key: string, value: any) => headers.set(key, value), 71 | getHeaders: () => Object.fromEntries(headers.entries()), 72 | end: (data: string) => { 73 | body = JSON.parse(data); 74 | }, 75 | } as unknown as NextApiResponse; 76 | 77 | const nextRequest = new NextApiRequestMock({ 78 | method: req.method, 79 | query: req.query, 80 | body: req.body, 81 | headers: req.headers, 82 | }); 83 | 84 | try { 85 | await openApiNextHandler(nextRequest, nextResponse); 86 | resolve({ 87 | statusCode: nextResponse.statusCode, 88 | headers: nextResponse.getHeaders(), 89 | body, 90 | }); 91 | } catch (error) { 92 | reject(error); 93 | } 94 | }); 95 | }; 96 | }; 97 | 98 | const t = initTRPC.meta().context().create(); 99 | 100 | describe('next adapter', () => { 101 | afterEach(() => { 102 | clearMocks(); 103 | }); 104 | 105 | test('with valid routes', async () => { 106 | const appRouter = t.router({ 107 | sayHelloQuery: t.procedure 108 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 109 | .input(z.object({ name: z.string() })) 110 | .output(z.object({ greeting: z.string() })) 111 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 112 | sayHelloMutation: t.procedure 113 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 114 | .input(z.object({ name: z.string() })) 115 | .output(z.object({ greeting: z.string() })) 116 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 117 | sayHelloSlash: t.procedure 118 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 119 | .input(z.object({ name: z.string() })) 120 | .output(z.object({ greeting: z.string() })) 121 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 122 | }); 123 | 124 | const openApiNextHandlerCaller = createOpenApiNextHandlerCaller({ 125 | router: appRouter, 126 | }); 127 | 128 | { 129 | const res = await openApiNextHandlerCaller({ 130 | method: 'GET', 131 | query: { trpc: 'say-hello', name: 'Lily' }, 132 | }); 133 | 134 | expect(res.statusCode).toBe(200); 135 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 136 | expect(createContextMock).toHaveBeenCalledTimes(1); 137 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 138 | expect(onErrorMock).toHaveBeenCalledTimes(0); 139 | 140 | clearMocks(); 141 | } 142 | { 143 | const res = await openApiNextHandlerCaller({ 144 | method: 'POST', 145 | query: { trpc: 'say-hello' }, 146 | body: { name: 'Lily' }, 147 | headers: { 'content-type': 'application/json' }, 148 | }); 149 | 150 | expect(res.statusCode).toBe(200); 151 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 152 | expect(createContextMock).toHaveBeenCalledTimes(1); 153 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 154 | expect(onErrorMock).toHaveBeenCalledTimes(0); 155 | 156 | clearMocks(); 157 | } 158 | { 159 | const res = await openApiNextHandlerCaller({ 160 | method: 'GET', 161 | query: { trpc: ['say', 'hello'], name: 'Lily' }, 162 | }); 163 | 164 | expect(res.statusCode).toBe(200); 165 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 166 | expect(createContextMock).toHaveBeenCalledTimes(1); 167 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 168 | expect(onErrorMock).toHaveBeenCalledTimes(0); 169 | } 170 | }); 171 | 172 | test('with invalid path', async () => { 173 | const appRouter = t.router({}); 174 | 175 | const openApiNextHandlerCaller = createOpenApiNextHandlerCaller({ 176 | router: appRouter, 177 | }); 178 | 179 | const res = await openApiNextHandlerCaller({ 180 | method: 'GET', 181 | query: {}, 182 | }); 183 | 184 | expect(res.statusCode).toBe(500); 185 | expect(res.body).toEqual({ 186 | message: 'Query "trpc" not found - is the `trpc-to-openapi` file named `[...trpc].ts`?', 187 | code: 'INTERNAL_SERVER_ERROR', 188 | }); 189 | expect(createContextMock).toHaveBeenCalledTimes(0); 190 | expect(responseMetaMock).toHaveBeenCalledTimes(0); 191 | expect(onErrorMock).toHaveBeenCalledTimes(1); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /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 | import { Socket } from 'net'; 15 | 16 | const createContextMock = jest.fn(); 17 | const responseMetaMock = jest.fn(); 18 | const onErrorMock = jest.fn(); 19 | 20 | const clearMocks = () => { 21 | createContextMock.mockClear(); 22 | responseMetaMock.mockClear(); 23 | onErrorMock.mockClear(); 24 | }; 25 | 26 | const createOpenApiNuxtHandlerCaller = ( 27 | handlerOpts: CreateOpenApiNuxtHandlerOptions, 28 | ) => { 29 | const openApiNuxtHandler = createOpenApiNuxtHandler({ 30 | router: handlerOpts.router, 31 | createContext: handlerOpts.createContext ?? createContextMock, 32 | responseMeta: handlerOpts.responseMeta ?? responseMetaMock, 33 | onError: handlerOpts.onError ?? onErrorMock, 34 | } as never); 35 | 36 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor */ 37 | return (req: { 38 | method: RequestMethod; 39 | params: Record; 40 | url?: string; 41 | body?: any; 42 | }) => 43 | new Promise<{ 44 | statusCode: number; 45 | headers: Record; 46 | body: OpenApiResponse; 47 | /* eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor */ 48 | }>(async (resolve, reject) => { 49 | const headers = new Map(); 50 | let body: any; 51 | 52 | const res: any = { 53 | statusCode: undefined, 54 | setHeader: (key: string, value: any) => headers.set(key, value), 55 | end: (data: string) => { 56 | body = JSON.parse(data); 57 | }, 58 | }; 59 | 60 | const mockReq = httpMocks.createRequest({ 61 | socket: new Socket(), 62 | body: req.body, 63 | method: req.method, 64 | url: req.url, 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | }); 69 | const mockRes = httpMocks.createResponse({ 70 | req: mockReq, 71 | }); 72 | mockRes.setHeader = res.setHeader; 73 | mockRes.end = res.end; 74 | const event = new H3Event(mockReq, mockRes); 75 | event.context.params = req.params; 76 | try { 77 | await openApiNuxtHandler(event); 78 | resolve({ 79 | statusCode: mockRes.statusCode, 80 | headers, 81 | body, 82 | }); 83 | } catch (error) { 84 | reject(error); 85 | } 86 | }); 87 | }; 88 | 89 | const t = initTRPC.meta().context().create(); 90 | 91 | describe('nuxt adapter', () => { 92 | afterEach(() => { 93 | clearMocks(); 94 | }); 95 | 96 | test('with valid routes', async () => { 97 | const appRouter = t.router({ 98 | sayHelloQuery: t.procedure 99 | .meta({ openapi: { method: 'GET', path: '/say-hello' } }) 100 | .input(z.object({ name: z.string() })) 101 | .output(z.object({ greeting: z.string() })) 102 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 103 | sayHelloMutation: t.procedure 104 | .meta({ openapi: { method: 'POST', path: '/say-hello' } }) 105 | .input(z.object({ name: z.string() })) 106 | .output(z.object({ greeting: z.string() })) 107 | .mutation(({ input }) => ({ greeting: `Hello ${input.name}!` })), 108 | sayHelloSlash: t.procedure 109 | .meta({ openapi: { method: 'GET', path: '/say/hello' } }) 110 | .input(z.object({ name: z.string() })) 111 | .output(z.object({ greeting: z.string() })) 112 | .query(({ input }) => ({ greeting: `Hello ${input.name}!` })), 113 | }); 114 | 115 | const openApiNuxtHandlerCaller = createOpenApiNuxtHandlerCaller({ 116 | router: appRouter, 117 | }); 118 | 119 | { 120 | const res = await openApiNuxtHandlerCaller({ 121 | method: 'GET', 122 | params: { trpc: 'say-hello' }, 123 | url: '/api/say-hello?name=Lily', 124 | }); 125 | 126 | // expect(res.statusCode).toBe(200); 127 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 128 | expect(createContextMock).toHaveBeenCalledTimes(1); 129 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 130 | expect(onErrorMock).toHaveBeenCalledTimes(0); 131 | 132 | clearMocks(); 133 | } 134 | { 135 | const res = await openApiNuxtHandlerCaller({ 136 | method: 'POST', 137 | params: { trpc: 'say-hello' }, 138 | body: { name: 'Lily' }, 139 | }); 140 | 141 | expect(res.statusCode).toBe(200); 142 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 143 | expect(createContextMock).toHaveBeenCalledTimes(1); 144 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 145 | expect(onErrorMock).toHaveBeenCalledTimes(0); 146 | 147 | clearMocks(); 148 | } 149 | { 150 | const res = await openApiNuxtHandlerCaller({ 151 | method: 'GET', 152 | params: { trpc: 'say/hello' }, 153 | url: '/api/say/hello?name=Lily', 154 | }); 155 | 156 | expect(res.statusCode).toBe(200); 157 | expect(res.body).toEqual({ greeting: 'Hello Lily!' }); 158 | expect(createContextMock).toHaveBeenCalledTimes(1); 159 | expect(responseMetaMock).toHaveBeenCalledTimes(1); 160 | expect(onErrorMock).toHaveBeenCalledTimes(0); 161 | } 162 | }); 163 | 164 | test('with invalid path', async () => { 165 | const appRouter = t.router({}); 166 | 167 | const openApiNuxtHandlerCaller = createOpenApiNuxtHandlerCaller({ 168 | router: appRouter, 169 | }); 170 | 171 | const res = await openApiNuxtHandlerCaller({ 172 | method: 'GET', 173 | params: {}, 174 | }); 175 | 176 | expect(res.statusCode).toBe(500); 177 | expect(res.body).toEqual({ 178 | message: 'Query "trpc" not found - is the `trpc-to-openapi` file named `[...trpc].ts`?', 179 | code: 'INTERNAL_SERVER_ERROR', 180 | }); 181 | expect(createContextMock).toHaveBeenCalledTimes(0); 182 | expect(responseMetaMock).toHaveBeenCalledTimes(0); 183 | expect(onErrorMock).toHaveBeenCalledTimes(1); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------