├── .dockerignore ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml ├── release.yml ├── renovate.json5 └── workflows │ ├── prerelease.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── eslint.config.js ├── examples └── openapi.ts ├── jest.config.ts ├── jest.setup.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── index.ts ├── plugin.test.ts ├── plugin.ts ├── serializerCompiler.test.ts ├── serializerCompiler.ts ├── transformer.test.ts ├── transformer.ts ├── validationError.ts ├── validatorCompiler.test.ts └── validatorCompiler.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # managed by skuba 2 | .gantry/ 3 | .git/ 4 | .idea/ 5 | .serverless/ 6 | .vscode/ 7 | node_modules*/ 8 | 9 | /coverage*/ 10 | /dist*/ 11 | /lib*/ 12 | /tmp*/ 13 | 14 | .DS_Store 15 | npm-debug.log 16 | yarn-error.log 17 | # end managed by skuba 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @seek-oss/skuba-maintainers 2 | 3 | # Configured by Renovate 4 | package.json 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [samchungy] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | labels: 13 | - 'chore' 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - chore 5 | categories: 6 | - title: Breaking Changes 🛠 7 | labels: 8 | - breaking change 9 | - title: New Features 🎉 10 | labels: 11 | - enhancement 12 | - title: Other Changes 13 | labels: 14 | - '*' 15 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: ['github>seek-oss/rynovate'], 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Prerelease 2 | 3 | on: 4 | release: 5 | types: 6 | - prereleased 7 | 8 | permissions: {} 9 | 10 | env: 11 | COREPACK_DEFAULT_TO_LATEST: 0 12 | 13 | jobs: 14 | release: 15 | name: Version & Publish 16 | permissions: 17 | contents: write 18 | id-token: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out repo 22 | uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Node.js 20.x 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 20.x 30 | registry-url: 'https://registry.npmjs.org' 31 | 32 | - name: Set up pnpm 33 | run: corepack enable pnpm 34 | 35 | - name: Install dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Create Beta Branch 39 | run: | 40 | git config user.name github-actions[bot] 41 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 42 | git checkout -b beta 43 | git push --force origin beta --set-upstream 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Version Package 48 | run: npm version ${{ github.event.release.tag_name }} --git-tag-version=false 49 | 50 | - name: Push package.json and tags 51 | run: | 52 | sha=$(gh api --method PUT /repos/:owner/:repo/contents/$FILE_TO_COMMIT \ 53 | -f message="Release ${{ github.event.release.tag_name }}" \ 54 | -f content="$( base64 -i $FILE_TO_COMMIT )" \ 55 | -f encoding="base64" \ 56 | -f branch="$DESTINATION_BRANCH" \ 57 | -f sha="$( git rev-parse $DESTINATION_BRANCH:$FILE_TO_COMMIT )" --jq '.commit.sha') 58 | gh api --method PATCH /repos/:owner/:repo/git/refs/tags/${{ github.event.release.tag_name }} \ 59 | -f sha="$sha" -F force=true 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | FILE_TO_COMMIT: package.json 63 | DESTINATION_BRANCH: beta 64 | 65 | - name: Publish to npm 66 | run: pnpm build && npm publish --provenance --tag beta 67 | env: 68 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | permissions: {} 9 | 10 | env: 11 | COREPACK_DEFAULT_TO_LATEST: 0 12 | 13 | jobs: 14 | release: 15 | name: Version & Publish 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | id-token: write 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Check out repo 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Node.js 20.x 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 20.x 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | - name: Set up pnpm 34 | run: corepack enable pnpm 35 | 36 | - name: Install dependencies 37 | run: pnpm install --frozen-lockfile 38 | 39 | - name: Create Release Branch 40 | run: | 41 | git config user.name github-actions[bot] 42 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 43 | git checkout -b release 44 | git push --force origin release --set-upstream 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | - name: Version Package 49 | run: npm version ${{ github.event.release.tag_name }} --git-tag-version=false 50 | 51 | - name: Push package.json and tags 52 | run: | 53 | sha=$(gh api --method PUT /repos/:owner/:repo/contents/$FILE_TO_COMMIT \ 54 | -f message="Release ${{ github.event.release.tag_name }}" \ 55 | -f content="$( base64 -i $FILE_TO_COMMIT )" \ 56 | -f encoding="base64" \ 57 | -f branch="$DESTINATION_BRANCH" \ 58 | -f sha="$( git rev-parse $DESTINATION_BRANCH:$FILE_TO_COMMIT )" --jq '.commit.sha') 59 | gh api --method PATCH /repos/:owner/:repo/git/refs/tags/${{ github.event.release.tag_name }} \ 60 | -f sha="$sha" -F force=true 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | FILE_TO_COMMIT: package.json 64 | DESTINATION_BRANCH: release 65 | 66 | - name: Raise Release PR 67 | run: | 68 | gh pr create -H release -B main --title "Release ${{ github.event.release.tag_name }}" --body "Please merge this with a Merge Request to update main

[${{ github.event.release.tag_name }}](${{ github.event.release.html_url }})

${{ github.event.release.body }}" -l "chore" 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | 72 | - name: Publish to npm 73 | run: pnpm build && npm publish --provenance 74 | env: 75 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 76 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'main' 8 | permissions: {} 9 | 10 | env: 11 | COREPACK_DEFAULT_TO_LATEST: 0 12 | 13 | jobs: 14 | validate: 15 | name: Lint & Test 16 | permissions: 17 | checks: write 18 | contents: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Set up Node.js 20.x 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 20.x 28 | 29 | - name: Set up pnpm 30 | run: corepack enable pnpm 31 | 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: Test 36 | run: pnpm test:ci 37 | 38 | - name: Lint 39 | run: pnpm lint 40 | 41 | - name: Build 42 | run: pnpm build 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # managed by skuba 2 | .idea/* 3 | .vscode/* 4 | !.vscode/extensions.json 5 | 6 | .cdk.staging/ 7 | .serverless/ 8 | cdk.out/ 9 | cdk.context.json 10 | node_modules*/ 11 | 12 | /coverage*/ 13 | /dist*/ 14 | /lib*/ 15 | /tmp*/ 16 | 17 | .DS_Store 18 | .eslintcache 19 | .pnpm-debug.log 20 | *.tgz 21 | *.tsbuildinfo 22 | npm-debug.log 23 | package-lock.json 24 | yarn-error.log 25 | # end managed by skuba 26 | 27 | # managed by crackle 28 | /dist 29 | # end managed by crackle 30 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # managed by skuba 2 | package-manager-strict-version=true 3 | public-hoist-pattern[]="@types*" 4 | public-hoist-pattern[]="*eslint*" 5 | public-hoist-pattern[]="*prettier*" 6 | public-hoist-pattern[]="esbuild" 7 | public-hoist-pattern[]="jest" 8 | public-hoist-pattern[]="tsconfig-seek" 9 | # end managed by skuba 10 | 11 | public-hoist-pattern[]="openapi-types" 12 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # managed by skuba 2 | # Gantry resource files support non-standard template syntax 3 | /.gantry/**/*.yaml 4 | /.gantry/**/*.yml 5 | gantry*.yaml 6 | gantry*.yml 7 | pnpm-lock.yaml 8 | coverage 9 | # end managed by skuba 10 | 11 | examples/**/*.yml 12 | examples/**/*.html 13 | src/openapi3-ts/* 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('skuba/config/prettier'); 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ### MIT License 2 | 3 | Copyright (c) 2020 Sam Chung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

fastify-zod-openapi

3 |

4 |

5 | Fastify type provider, validation, serialization and @fastify/swagger support for zod-openapi. 6 |

7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | ## Install 18 | 19 | Install via `npm`, `pnpm` or `pnpm`: 20 | 21 | ```bash 22 | npm install zod zod-openapi fastify-zod-openapi 23 | ## or 24 | pnpm add zod zod-openapi fastify-zod-openapi 25 | ## or 26 | pnpm install zod-openapi fastify-zod-openapi 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```ts 32 | import 'zod-openapi/extend'; 33 | import fastify from 'fastify'; 34 | import { 35 | type FastifyZodOpenApiSchema, 36 | type FastifyZodOpenApiTypeProvider, 37 | serializerCompiler, 38 | validatorCompiler, 39 | } from 'fastify-zod-openapi'; 40 | import { z } from 'zod'; 41 | 42 | const app = fastify(); 43 | 44 | app.setValidatorCompiler(validatorCompiler); 45 | app.setSerializerCompiler(serializerCompiler); 46 | 47 | app.withTypeProvider().route({ 48 | method: 'POST', 49 | url: '/:jobId', 50 | schema: { 51 | body: z.object({ 52 | jobId: z.string().openapi({ 53 | description: 'Job ID', 54 | example: '60002023', 55 | }), 56 | }), 57 | response: { 58 | 201: z.object({ 59 | jobId: z.string().openapi({ 60 | description: 'Job ID', 61 | example: '60002023', 62 | }), 63 | }), 64 | }, 65 | } satisfies FastifyZodOpenApiSchema, 66 | handler: async (req, res) => { 67 | await res.send({ jobId: req.body.jobId }); 68 | }, 69 | }); 70 | 71 | await app.ready(); 72 | await app.listen({ port: 5000 }); 73 | ``` 74 | 75 | ## Usage with plugins 76 | 77 | ```ts 78 | import 'zod-openapi/extend'; 79 | import fastify from 'fastify'; 80 | import { 81 | type FastifyPluginAsyncZodOpenApi, 82 | type FastifyZodOpenApiSchema, 83 | serializerCompiler, 84 | validatorCompiler, 85 | } from 'fastify-zod-openapi'; 86 | import { z } from 'zod'; 87 | 88 | const app = fastify(); 89 | 90 | app.setValidatorCompiler(validatorCompiler); 91 | app.setSerializerCompiler(serializerCompiler); 92 | 93 | const plugin: FastifyPluginAsyncZodOpenApi = async (fastify, _opts) => { 94 | fastify.route({ 95 | method: 'POST', 96 | url: '/', 97 | // Define your schema 98 | schema: { 99 | body: z.object({ 100 | jobId: z.string().openapi({ 101 | description: 'Job ID', 102 | example: '60002023', 103 | }), 104 | }), 105 | response: { 106 | 201: z.object({ 107 | jobId: z.string().openapi({ 108 | description: 'Job ID', 109 | example: '60002023', 110 | }), 111 | }), 112 | }, 113 | } satisfies FastifyZodOpenApiSchema, 114 | handler: async (req, res) => { 115 | await res.send({ jobId: req.body.jobId }); 116 | }, 117 | }); 118 | }; 119 | 120 | app.register(plugin); 121 | ``` 122 | 123 | ## Usage with @fastify/swagger 124 | 125 | ```ts 126 | import 'zod-openapi/extend'; 127 | import fastifySwagger from '@fastify/swagger'; 128 | import fastifySwaggerUI from '@fastify/swagger-ui'; 129 | import fastify from 'fastify'; 130 | import { 131 | type FastifyZodOpenApiSchema, 132 | type FastifyZodOpenApiTypeProvider, 133 | fastifyZodOpenApiPlugin, 134 | fastifyZodOpenApiTransform, 135 | fastifyZodOpenApiTransformObject, 136 | serializerCompiler, 137 | validatorCompiler, 138 | } from 'fastify-zod-openapi'; 139 | import { z } from 'zod'; 140 | import { type ZodOpenApiVersion } from 'zod-openapi'; 141 | 142 | const app = fastify(); 143 | 144 | app.setValidatorCompiler(validatorCompiler); 145 | app.setSerializerCompiler(serializerCompiler); 146 | 147 | await app.register(fastifyZodOpenApiPlugin); 148 | await app.register(fastifySwagger, { 149 | openapi: { 150 | info: { 151 | title: 'hello world', 152 | version: '1.0.0', 153 | }, 154 | openapi: '3.0.3' satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0 155 | }, 156 | transform: fastifyZodOpenApiTransform, 157 | transformObject: fastifyZodOpenApiTransformObject, 158 | }); 159 | await app.register(fastifySwaggerUI, { 160 | routePrefix: '/documentation', 161 | }); 162 | 163 | app.withTypeProvider().route({ 164 | method: 'POST', 165 | url: '/', 166 | schema: { 167 | body: z.object({ 168 | jobId: z.string().openapi({ 169 | description: 'Job ID', 170 | example: '60002023', 171 | }), 172 | }), 173 | response: { 174 | 201: { 175 | content: { 176 | 'application/json': { 177 | schema: z.object({ 178 | jobId: z.string().openapi({ 179 | description: 'Job ID', 180 | example: '60002023', 181 | }), 182 | }), 183 | }, 184 | }, 185 | }, 186 | }, 187 | } satisfies FastifyZodOpenApiSchema, 188 | handler: async (_req, res) => 189 | res.send({ 190 | jobId: '60002023', 191 | }), 192 | }); 193 | await app.ready(); 194 | await app.listen({ port: 5000 }); 195 | ``` 196 | 197 | ### Declaring Components 198 | 199 | This library allows you to easily declare components. As an example: 200 | 201 | ```typescript 202 | const title = z.string().openapi({ 203 | description: 'Job title', 204 | example: 'My job', 205 | ref: 'jobTitle', // <- new field 206 | }); 207 | ``` 208 | 209 | Wherever `title` is used in your request/response schemas across your application, it will instead be created as a reference. 210 | 211 | ```json 212 | { "$ref": "#/components/schemas/jobTitle" } 213 | ``` 214 | 215 | For a further dive please follow the documentation [here](https://github.com/samchungy/zod-openapi#creating-components). 216 | 217 | If you wish to declare the components manually you will need to do so via the plugin's options. You will also need 218 | to create a custom SerializerCompiler to make use of [fast-json-stringify](https://github.com/fastify/fast-json-stringify). 219 | 220 | ```ts 221 | const components: ZodOpenApiComponentsObject = { schemas: { mySchema } }; 222 | await app.register(fastifyZodOpenApiPlugin, { 223 | components, 224 | }); 225 | 226 | const customSerializerCompiler = createSerializerCompiler({ 227 | components, 228 | }); 229 | ``` 230 | 231 | Alternatively, you can use `JSON.stringify` instead. 232 | 233 | ```ts 234 | const customSerializerCompiler = createSerializerCompiler({ 235 | stringify: JSON.stringify, 236 | }); 237 | ``` 238 | 239 | By default, this library assumes that if a response schema provided is not a Zod Schema, it is a JSON Schema and will naively pass it straight into `fast-json-stringify`. This will not work in conjunction with Fastify's schema registration. 240 | 241 | If you have other routes with response schemas which are not Zod Schemas, you can supply a `fallbackSerializer` to `createSerializerCompiler`. 242 | 243 | ```ts 244 | const customSerializerCompiler = createSerializerCompiler({ 245 | fallbackSerializer: ({ schema, url, method }) => customSerializer(schema), 246 | }); 247 | ``` 248 | 249 | Please note: the `responses`, `parameters` components do not appear to be supported by the `@fastify/swagger` library. 250 | 251 | ### Create Document Options 252 | 253 | If you wish to use [CreateDocumentOptions](https://github.com/samchungy/zod-openapi#createdocumentoptions), pass it in via the plugin options: 254 | 255 | ```ts 256 | await app.register(fastifyZodOpenApiPlugin, { 257 | documentOpts: { 258 | unionOneOf: true, 259 | }, 260 | }); 261 | ``` 262 | 263 | ### Custom Response Serializer 264 | 265 | The default response serializer `serializerCompiler` uses [fast-json-stringify](https://github.com/fastify/fast-json-stringify). Under the hood, the schema passed to the response is transformed using OpenAPI 3.1.0 and passed to `fast-json-stringify` as a JSON Schema. 266 | 267 | If are running into any compatibility issues or wish to restore the previous `JSON.stringify` functionality, you can use the `createSerializerCompiler` function. 268 | 269 | ```ts 270 | const customSerializerCompiler = createSerializerCompiler({ 271 | stringify: JSON.stringify, 272 | }); 273 | ``` 274 | 275 | ### Error Handling 276 | 277 | By default, `fastify-zod-openapi` emits request validation errors in a similar manner to `fastify` when used in conjunction with it's native JSON Schema error handling. 278 | 279 | As an example: 280 | 281 | ```json 282 | { 283 | "code": "FST_ERR_VALIDATION", 284 | "error": "Bad Request", 285 | "message": "params/jobId Expected number, received string", 286 | "statusCode": 400 287 | } 288 | ``` 289 | 290 | For responses, it will emit a 500 error along with a vague error which will protect your implementation details 291 | 292 | ```json 293 | { 294 | "code": "FST_ERR_RESPONSE_SERIALIZATION", 295 | "error": "Internal Server Error", 296 | "message": "Response does not match the schema", 297 | "statusCode": 500 298 | } 299 | ``` 300 | 301 | To customise this behaviour, you may follow the [fastify error handling](https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/#error-handling) guidance. 302 | 303 | #### Request Errors 304 | 305 | This library throws a `RequestValidationError` when a request fails to validate against your Zod Schemas 306 | 307 | ##### setErrorHandler 308 | 309 | ```ts 310 | fastify.setErrorHandler(function (error, request, reply) { 311 | if (error.validation) { 312 | const zodValidationErrors = error.validation.filter( 313 | (err) => err instanceof RequestValidationError, 314 | ); 315 | const zodIssues = zodValidationErrors.map((err) => err.params.issue); 316 | const originalError = zodValidationErrors?.[0]?.params.error; 317 | return reply.status(422).send({ 318 | zodIssues 319 | originalError 320 | }); 321 | } 322 | }); 323 | ``` 324 | 325 | ##### setSchemaErrorFormatter 326 | 327 | ```ts 328 | fastify.setSchemaErrorFormatter(function (errors, dataVar) { 329 | let message = `${dataVar}:`; 330 | for (const error of errors) { 331 | if (error instanceof RequestValidationError) { 332 | message += ` ${error.instancePath} ${error.keyword}`; 333 | } 334 | } 335 | 336 | return new Error(message); 337 | }); 338 | 339 | // { 340 | // code: 'FST_ERR_VALIDATION', 341 | // error: 'Bad Request', 342 | // message: 'querystring: /jobId invalid_type', 343 | // statusCode: 400, 344 | // } 345 | ``` 346 | 347 | ##### attachValidation 348 | 349 | ```ts 350 | app.withTypeProvider().get( 351 | '/', 352 | { 353 | schema: { 354 | querystring: z.object({ 355 | jobId: z.string().openapi({ 356 | description: 'Job ID', 357 | example: '60002023', 358 | }), 359 | }), 360 | }, 361 | attachValidation: true, 362 | }, 363 | (req, res) => { 364 | if (req.validationError?.validation) { 365 | const zodValidationErrors = req.validationError.validation.filter( 366 | (err) => err instanceof RequestValidationError, 367 | ); 368 | console.error(zodValidationErrors); 369 | } 370 | 371 | return res.send(req.query); 372 | }, 373 | ); 374 | ``` 375 | 376 | #### Response Errors 377 | 378 | ```ts 379 | app.setErrorHandler((error, _req, res) => { 380 | if (error instanceof ResponseSerializationError) { 381 | return res.status(500).send({ 382 | error: 'Bad response', 383 | }); 384 | } 385 | }); 386 | 387 | // { 388 | // error: 'Bad response'; 389 | // } 390 | ``` 391 | 392 | ## Credits 393 | 394 | [fastify-type-provider-zod](https://github.com/turkerdev/fastify-type-provider-zod): Big kudos to this library for lighting the way with how to create type providers, validators and serializers. fastify-zod-openapi is just an extension to this library whilst adding support for the functionality of zod-openapi. 395 | 396 | ## Development 397 | 398 | ### Prerequisites 399 | 400 | - Node.js LTS 401 | - pnpm 402 | 403 | ```shell 404 | pnpm install 405 | pnpm build 406 | ``` 407 | 408 | ### Test 409 | 410 | ```shell 411 | pnpm test 412 | ``` 413 | 414 | ### Lint 415 | 416 | ```shell 417 | # Fix issues 418 | pnpm format 419 | 420 | # Check for issues 421 | pnpm lint 422 | ``` 423 | 424 | ### Release 425 | 426 | To release a new version 427 | 428 | 1. Create a [new GitHub Release](https://github.com/samchungy/zod-openapi/releases/new) 429 | 2. Select `🏷️ Choose a tag`, enter a version number. eg. `v1.2.0` and click `+ Create new tag: vX.X.X on publish`. 430 | 3. Click the `Generate release notes` button and adjust the description. 431 | 4. Tick the `Set as the latest release` box and click `Publish release`. This will trigger the `Release` workflow. 432 | 5. Check the `Pull Requests` tab for a PR labelled `Release vX.X.X`. 433 | 6. Click `Merge Pull Request` on that Pull Request to update main with the new package version. 434 | 435 | To release a new beta version 436 | 437 | 1. Create a [new GitHub Release](https://github.com/samchungy/fastify-zod-openapi/releases/new) 438 | 2. Select `🏷️ Choose a tag`, enter a version number with a `-beta.X` suffix eg. `v1.2.0-beta.1` and click `+ Create new tag: vX.X.X-beta.X on publish`. 439 | 3. Click the `Generate release notes` button and adjust the description. 440 | 4. Tick the `Set as a pre-release` box and click `Publish release`. This will trigger the `Prerelease` workflow. 441 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const skuba = require('eslint-config-skuba'); 2 | const zodOpenapi = require('eslint-plugin-zod-openapi'); 3 | 4 | module.exports = [ 5 | { 6 | ignores: ['src/openapi3-ts/*'], 7 | }, 8 | ...skuba, 9 | { 10 | plugins: { 11 | 'zod-openapi': zodOpenapi, 12 | }, 13 | }, 14 | { 15 | files: ['examples/**/*/types/**/*.ts'], 16 | 17 | rules: { 18 | 'zod-openapi/require-openapi': 'error', 19 | 'zod-openapi/require-comment': 'error', 20 | 'zod-openapi/require-example': 'error', 21 | 'zod-openapi/prefer-openapi-last': 'error', 22 | 'zod-openapi/prefer-zod-default': 'error', 23 | }, 24 | }, 25 | ]; 26 | -------------------------------------------------------------------------------- /examples/openapi.ts: -------------------------------------------------------------------------------- 1 | import 'zod-openapi/extend'; 2 | 3 | import fastifySwagger from '@fastify/swagger'; 4 | import fastifySwaggerUI from '@fastify/swagger-ui'; 5 | import fastify from 'fastify'; 6 | import { z } from 'zod'; 7 | 8 | import { 9 | type FastifyZodOpenApiSchema, 10 | type FastifyZodOpenApiTypeProvider, 11 | fastifyZodOpenApiPlugin, 12 | fastifyZodOpenApiTransform, 13 | fastifyZodOpenApiTransformObject, 14 | serializerCompiler, 15 | validatorCompiler, 16 | } from '../src'; 17 | 18 | const createApp = async () => { 19 | const app = fastify(); 20 | 21 | app.setValidatorCompiler(validatorCompiler); 22 | app.setSerializerCompiler(serializerCompiler); 23 | 24 | await app.register(fastifyZodOpenApiPlugin); 25 | await app.register(fastifySwagger, { 26 | openapi: { 27 | info: { 28 | title: 'hello world', 29 | version: '1.0.0', 30 | }, 31 | }, 32 | transform: fastifyZodOpenApiTransform, 33 | transformObject: fastifyZodOpenApiTransformObject, 34 | }); 35 | await app.register(fastifySwaggerUI, { 36 | routePrefix: '/documentation', 37 | }); 38 | 39 | const JobIdSchema = z.string().openapi({ 40 | description: 'Job ID', 41 | example: '60002023', 42 | ref: 'jobId', 43 | }); 44 | 45 | app.withTypeProvider().route({ 46 | method: 'POST', 47 | url: '/:jobId', 48 | schema: { 49 | params: z.object({ 50 | foo: z.string().openapi({ 51 | description: 'path parameter example', 52 | example: 'bar', 53 | }), 54 | }), 55 | querystring: z.object({ 56 | baz: z.string().openapi({ 57 | description: 'query string example', 58 | example: 'quz', 59 | }), 60 | }), 61 | body: z.object({ 62 | jobId: JobIdSchema, 63 | }), 64 | headers: z.object({ 65 | 'my-header': z.string().openapi({ 66 | description: 'header string example', 67 | example: 'xyz', 68 | }), 69 | }), 70 | response: { 71 | 200: z.object({ 72 | jobId: JobIdSchema, 73 | }), 74 | 201: { 75 | content: { 76 | 'application/json': { 77 | example: { jobId: '123' }, 78 | schema: z.object({ 79 | jobId: z.string().openapi({ 80 | description: 'Job ID', 81 | example: '60002023', 82 | }), 83 | }), 84 | }, 85 | }, 86 | }, 87 | }, 88 | } satisfies FastifyZodOpenApiSchema, 89 | handler: async (_req, res) => 90 | res.send({ 91 | jobId: '60002023', 92 | }), 93 | }); 94 | await app.ready(); 95 | return app; 96 | }; 97 | 98 | const app = createApp(); 99 | 100 | export default app; 101 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Jest } from 'skuba'; 2 | 3 | export default Jest.mergePreset({ 4 | coveragePathIgnorePatterns: ['src/testing'], 5 | coverageThreshold: { 6 | global: { 7 | branches: 0, 8 | functions: 0, 9 | lines: 0, 10 | statements: 0, 11 | }, 12 | }, 13 | setupFiles: ['/jest.setup.ts'], 14 | testPathIgnorePatterns: ['/test\\.ts'], 15 | }); 16 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | process.env.ENVIRONMENT = 'test'; 2 | 3 | export {}; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-zod-openapi", 3 | "version": "4.1.2", 4 | "description": "Fastify plugin for zod-openapi", 5 | "keywords": [ 6 | "typescript", 7 | "json-schema", 8 | "swagger", 9 | "openapi", 10 | "openapi3", 11 | "zod", 12 | "zod-openapi", 13 | "fastify", 14 | "plugin", 15 | "type", 16 | "provider" 17 | ], 18 | "homepage": "https://github.com/samchungy/fastify-zod-openapi#readme", 19 | "bugs": { 20 | "url": "https://github.com/samchungy/fastify-zod-openapi/issues" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+ssh://git@github.com/samchungy/fastify-zod-openapi.git" 25 | }, 26 | "license": "MIT", 27 | "sideEffects": false, 28 | "exports": { 29 | ".": { 30 | "types": { 31 | "import": "./dist/index.d.mts", 32 | "require": "./dist/index.d.ts" 33 | }, 34 | "import": "./dist/index.mjs", 35 | "require": "./dist/index.cjs" 36 | }, 37 | "./package.json": "./package.json" 38 | }, 39 | "main": "./dist/index.cjs", 40 | "module": "./dist/index.mjs", 41 | "types": "./dist/index.d.ts", 42 | "files": [ 43 | "dist" 44 | ], 45 | "scripts": { 46 | "build": "crackle package", 47 | "format": "skuba format", 48 | "lint": "skuba lint", 49 | "prepare": "pnpm build", 50 | "start": "skuba node --port=5000 examples/openapi.ts", 51 | "test": "skuba test", 52 | "test:ci": "skuba test --coverage", 53 | "test:watch": "skuba test --watch" 54 | }, 55 | "dependencies": { 56 | "@fastify/error": "^4.0.0", 57 | "fast-json-stringify": "^6.0.0", 58 | "fastify-plugin": "^5.0.0" 59 | }, 60 | "devDependencies": { 61 | "@crackle/cli": "0.15.5", 62 | "@fastify/swagger": "9.4.2", 63 | "@fastify/swagger-ui": "5.2.1", 64 | "@fastify/under-pressure": "9.0.3", 65 | "@types/node": "22.13.1", 66 | "eslint-plugin-zod-openapi": "1.0.0", 67 | "fastify": "5.2.1", 68 | "skuba": "9.1.0", 69 | "zod": "3.24.1", 70 | "zod-openapi": "4.2.3" 71 | }, 72 | "peerDependencies": { 73 | "@fastify/swagger": "^9.0.0", 74 | "@fastify/swagger-ui": "^5.0.1", 75 | "fastify": "5", 76 | "zod": "^3.21.4", 77 | "zod-openapi": "^4.2.0" 78 | }, 79 | "peerDependenciesMeta": { 80 | "@fastify/swagger": { 81 | "optional": true 82 | }, 83 | "@fastify/swagger-ui": { 84 | "optional": true 85 | } 86 | }, 87 | "packageManager": "pnpm@9.10.0", 88 | "engines": { 89 | "node": ">=20" 90 | }, 91 | "publishConfig": { 92 | "provenance": true, 93 | "registry": "https://registry.npmjs.org/" 94 | }, 95 | "skuba": { 96 | "entryPoint": "src/index.ts", 97 | "template": "oss-npm-package", 98 | "type": "package", 99 | "version": "9.0.1" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './plugin'; 2 | export * from './serializerCompiler'; 3 | export * from './transformer'; 4 | export * from './validatorCompiler'; 5 | export * from './validationError'; 6 | -------------------------------------------------------------------------------- /src/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import 'zod-openapi/extend'; 2 | import fastifySwagger from '@fastify/swagger'; 3 | import fastifySwaggerUI from '@fastify/swagger-ui'; 4 | import fastify from 'fastify'; 5 | import { z } from 'zod'; 6 | 7 | import type { 8 | FastifyPluginAsyncZodOpenApi, 9 | FastifyPluginCallbackZodOpenApi, 10 | FastifyZodOpenApiTypeProvider, 11 | } from './plugin'; 12 | import { serializerCompiler } from './serializerCompiler'; 13 | import type { FastifyZodOpenApiSchema } from './transformer'; 14 | import { validatorCompiler } from './validatorCompiler'; 15 | 16 | describe('plugin basics', () => { 17 | it('should pass a valid response', async () => { 18 | const app = fastify(); 19 | 20 | app.setValidatorCompiler(validatorCompiler); 21 | app.setSerializerCompiler(serializerCompiler); 22 | await app.register(fastifySwagger); 23 | await app.register(fastifySwaggerUI, { 24 | routePrefix: '/documentation', 25 | }); 26 | 27 | app.withTypeProvider().post( 28 | '/', 29 | { 30 | schema: { 31 | body: z.object({ 32 | jobId: z.string().openapi({ 33 | description: 'Job ID', 34 | example: '60002023', 35 | }), 36 | }), 37 | response: { 38 | 200: { 39 | content: { 40 | 'application/json': { 41 | schema: z.object({ 42 | jobId: z.string().openapi({ 43 | description: 'Job ID', 44 | example: '60002023', 45 | }), 46 | }), 47 | }, 48 | }, 49 | }, 50 | }, 51 | } satisfies FastifyZodOpenApiSchema, 52 | }, 53 | async (_req, res) => 54 | res.send({ 55 | jobId: '60002023', 56 | }), 57 | ); 58 | await app.ready(); 59 | 60 | const result = await app.inject().post('/').body({ jobId: '60002023' }); 61 | 62 | expect(result.json()).toEqual({ jobId: '60002023' }); 63 | }); 64 | 65 | it('should pass a short form response', async () => { 66 | const app = fastify(); 67 | 68 | app.setSerializerCompiler(serializerCompiler); 69 | app.withTypeProvider().post( 70 | '/', 71 | { 72 | schema: { 73 | response: { 74 | 200: z.object({ 75 | jobId: z.string().openapi({ 76 | description: 'Job ID', 77 | example: '60002023', 78 | }), 79 | }), 80 | }, 81 | }, 82 | }, 83 | async (_req, res) => 84 | res.send({ 85 | jobId: '60002023', 86 | }), 87 | ); 88 | await app.ready(); 89 | 90 | const result = await app.inject().post('/'); 91 | 92 | expect(result.json()).toEqual({ jobId: '60002023' }); 93 | }); 94 | 95 | it('should fail an invalid response', async () => { 96 | const app = fastify(); 97 | 98 | app.setSerializerCompiler(serializerCompiler); 99 | app.withTypeProvider().post( 100 | '/', 101 | { 102 | schema: { 103 | response: { 104 | 200: { 105 | content: { 106 | 'application/json': { 107 | schema: z.object({ 108 | jobId: z.string().openapi({ 109 | description: 'Job ID', 110 | example: '60002023', 111 | }), 112 | }), 113 | }, 114 | }, 115 | }, 116 | }, 117 | } satisfies FastifyZodOpenApiSchema, 118 | }, 119 | async (_req, res) => res.send({ jobId: 1 as unknown as string }), 120 | ); 121 | await app.ready(); 122 | 123 | const result = await app.inject().post('/'); 124 | 125 | expect(result.statusCode).toBe(500); 126 | expect(result.json()).toMatchInlineSnapshot(` 127 | { 128 | "code": "FST_ERR_RESPONSE_SERIALIZATION", 129 | "error": "Internal Server Error", 130 | "message": "Response does not match the schema", 131 | "statusCode": 500, 132 | } 133 | `); 134 | }); 135 | }); 136 | 137 | describe('FastifyPluginAsyncZodOpenApi', () => { 138 | it('should work with an async plugin', async () => { 139 | const plugin: FastifyPluginAsyncZodOpenApi = async ( 140 | fastifyInstance, 141 | _opts, 142 | // eslint-disable-next-line @typescript-eslint/require-await 143 | ) => { 144 | fastifyInstance.route({ 145 | method: 'POST', 146 | url: '/', 147 | // Define your schema 148 | schema: { 149 | body: z.object({ 150 | jobId: z.string().openapi({ 151 | description: 'Job ID', 152 | example: '60002023', 153 | }), 154 | }), 155 | response: { 156 | 201: z.object({ 157 | jobId: z.string().openapi({ 158 | description: 'Job ID', 159 | example: '60002023', 160 | }), 161 | }), 162 | }, 163 | } satisfies FastifyZodOpenApiSchema, 164 | handler: async (req, res) => { 165 | await res.send({ jobId: req.body.jobId }); 166 | }, 167 | }); 168 | }; 169 | 170 | const app = fastify(); 171 | 172 | app.setValidatorCompiler(validatorCompiler); 173 | app.setSerializerCompiler(serializerCompiler); 174 | await app.register(plugin); 175 | 176 | await app.ready(); 177 | 178 | const result = await app.inject().post('/').body({ jobId: '60002023' }); 179 | 180 | expect(result.json()).toEqual({ jobId: '60002023' }); 181 | }); 182 | 183 | it('should work with a callback plugin', async () => { 184 | const plugin: FastifyPluginCallbackZodOpenApi = ( 185 | fastifyInstance, 186 | _opts, 187 | done, 188 | ) => { 189 | fastifyInstance.route({ 190 | method: 'POST', 191 | url: '/', 192 | // Define your schema 193 | schema: { 194 | body: z.object({ 195 | jobId: z.string().openapi({ 196 | description: 'Job ID', 197 | example: '60002023', 198 | }), 199 | }), 200 | response: { 201 | 201: z.object({ 202 | jobId: z.string().openapi({ 203 | description: 'Job ID', 204 | example: '60002023', 205 | }), 206 | }), 207 | }, 208 | } satisfies FastifyZodOpenApiSchema, 209 | handler: async (req, res) => { 210 | await res.send({ jobId: req.body.jobId }); 211 | }, 212 | }); 213 | done(); 214 | }; 215 | 216 | const app = fastify(); 217 | 218 | app.setValidatorCompiler(validatorCompiler); 219 | app.setSerializerCompiler(serializerCompiler); 220 | await app.register(plugin); 221 | 222 | await app.ready(); 223 | 224 | const result = await app.inject().post('/').body({ jobId: '60002023' }); 225 | 226 | expect(result.json()).toEqual({ jobId: '60002023' }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FastifyPluginAsync, 3 | FastifyPluginCallback, 4 | FastifyPluginOptions, 5 | FastifyTypeProvider, 6 | RawServerBase, 7 | RawServerDefault, 8 | } from 'fastify'; 9 | import fp from 'fastify-plugin'; 10 | import type { ZodType, z } from 'zod'; 11 | import type { 12 | CreateDocumentOptions, 13 | ZodOpenApiComponentsObject, 14 | } from 'zod-openapi'; 15 | import { 16 | type ComponentsObject as ApiComponentsObject, 17 | getDefaultComponents, 18 | } from 'zod-openapi/api'; 19 | 20 | import type { RequestValidationError } from './validationError'; 21 | 22 | export const FASTIFY_ZOD_OPENAPI_CONFIG = Symbol('fastify-zod-openapi-config'); 23 | export const FASTIFY_ZOD_OPENAPI_COMPONENTS = Symbol( 24 | 'fastify-zod-openapi-components', 25 | ); 26 | 27 | export interface FastifyZodOpenApiOpts { 28 | components?: ZodOpenApiComponentsObject; 29 | documentOpts?: CreateDocumentOptions; 30 | } 31 | 32 | interface FastifyZodOpenApiConfig { 33 | components: ApiComponentsObject; 34 | documentOpts?: CreateDocumentOptions; 35 | } 36 | 37 | declare module 'fastify' { 38 | interface FastifySchema { 39 | [FASTIFY_ZOD_OPENAPI_CONFIG]?: FastifyZodOpenApiConfig; 40 | } 41 | 42 | interface FastifyValidationResult { 43 | errors?: RequestValidationError[]; 44 | } 45 | } 46 | 47 | declare module 'openapi-types' { 48 | // eslint-disable-next-line @typescript-eslint/no-namespace 49 | namespace OpenAPIV3 { 50 | interface Document { 51 | [FASTIFY_ZOD_OPENAPI_COMPONENTS]?: ApiComponentsObject; 52 | } 53 | } 54 | } 55 | 56 | export interface FastifyZodOpenApiTypeProvider extends FastifyTypeProvider { 57 | validator: this['schema'] extends ZodType ? z.infer : unknown; 58 | serializer: this['schema'] extends ZodType 59 | ? z.input 60 | : unknown; 61 | } 62 | 63 | export type FastifyZodOpenApi = FastifyPluginAsync; 64 | 65 | // eslint-disable-next-line @typescript-eslint/require-await 66 | const fastifyZodOpenApi: FastifyZodOpenApi = async (fastify, opts) => { 67 | const components = getDefaultComponents(opts.components); 68 | 69 | fastify.addHook('onRoute', ({ schema }) => { 70 | if (!schema || schema.hide) { 71 | return; 72 | } 73 | 74 | schema[FASTIFY_ZOD_OPENAPI_CONFIG] ??= { 75 | components, 76 | documentOpts: opts.documentOpts, 77 | }; 78 | }); 79 | }; 80 | 81 | export const fastifyZodOpenApiPlugin = fp(fastifyZodOpenApi, { 82 | name: 'fastify-zod-openapi', 83 | }); 84 | 85 | /** 86 | * FastifyPluginCallbackZodOpenApi with Zod automatic type inference 87 | * 88 | * @example 89 | * ```typescript 90 | * import { FastifyPluginCallbackZodOpenApi } from "fastify-zod-openapi" 91 | * 92 | * const plugin: FastifyPluginCallbackZodOpenApi = (fastify, options, done) => { 93 | * done() 94 | * } 95 | * ``` 96 | */ 97 | export type FastifyPluginCallbackZodOpenApi< 98 | Options extends FastifyPluginOptions = Record, 99 | Server extends RawServerBase = RawServerDefault, 100 | > = FastifyPluginCallback; 101 | 102 | /** 103 | * FastifyPluginAsyncZodOpenApi with Zod automatic type inference 104 | * 105 | * @example 106 | * ```typescript 107 | * import { FastifyPluginAsyncZodOpenApi } from "fastify-zod-openapi" 108 | * 109 | * const plugin: FastifyPluginAsyncZodOpenApi = async (fastify, options) => { 110 | * } 111 | * ``` 112 | */ 113 | export type FastifyPluginAsyncZodOpenApi< 114 | Options extends FastifyPluginOptions = Record, 115 | Server extends RawServerBase = RawServerDefault, 116 | > = FastifyPluginAsync; 117 | -------------------------------------------------------------------------------- /src/serializerCompiler.test.ts: -------------------------------------------------------------------------------- 1 | import 'zod-openapi/extend'; 2 | 3 | import UnderPressure from '@fastify/under-pressure'; 4 | import fastify from 'fastify'; 5 | import { z } from 'zod'; 6 | import type { ZodOpenApiResponsesObject } from 'zod-openapi'; 7 | 8 | import type { FastifyZodOpenApiTypeProvider } from './plugin'; 9 | import { 10 | createSerializerCompiler, 11 | serializerCompiler, 12 | } from './serializerCompiler'; 13 | import { ResponseSerializationError } from './validationError'; 14 | 15 | describe('serializerCompiler', () => { 16 | it('should pass a valid response', async () => { 17 | const app = fastify(); 18 | 19 | app.setSerializerCompiler(serializerCompiler); 20 | app.withTypeProvider().post( 21 | '/', 22 | { 23 | schema: { 24 | response: { 25 | 200: { 26 | content: { 27 | 'application/json': { 28 | schema: z.object({ 29 | jobId: z.string().openapi({ 30 | description: 'Job ID', 31 | example: '60002023', 32 | }), 33 | }), 34 | }, 35 | }, 36 | }, 37 | } satisfies ZodOpenApiResponsesObject, 38 | }, 39 | }, 40 | async (_req, res) => res.send({ jobId: '60002023' }), 41 | ); 42 | await app.ready(); 43 | 44 | const result = await app.inject().post('/'); 45 | 46 | expect(result.json()).toEqual({ jobId: '60002023' }); 47 | }); 48 | 49 | it('should pass a short form response', async () => { 50 | const app = fastify(); 51 | 52 | app.setSerializerCompiler(serializerCompiler); 53 | app.withTypeProvider().post( 54 | '/', 55 | { 56 | schema: { 57 | response: { 58 | 200: z.object({ 59 | jobId: z.string().openapi({ 60 | description: 'Job ID', 61 | example: '60002023', 62 | }), 63 | }), 64 | }, 65 | }, 66 | }, 67 | async (_req, res) => 68 | res.send({ 69 | jobId: '60002023', 70 | }), 71 | ); 72 | await app.ready(); 73 | 74 | const result = await app.inject().post('/'); 75 | 76 | expect(result.json()).toEqual({ jobId: '60002023' }); 77 | }); 78 | 79 | it('should handle a route with a JSON schema', async () => { 80 | const app = fastify(); 81 | 82 | app.setSerializerCompiler(serializerCompiler); 83 | app.post( 84 | '/', 85 | { 86 | schema: { 87 | response: { 88 | 200: { 89 | type: 'object', 90 | properties: { 91 | jobId: { type: 'string' }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | async (_req, res) => 98 | res.send({ 99 | jobId: '60002023', 100 | }), 101 | ); 102 | 103 | await app.ready(); 104 | 105 | const result = await app.inject().post('/'); 106 | 107 | expect(result.json()).toEqual({ jobId: '60002023' }); 108 | }); 109 | 110 | it('should work with under pressure', async () => { 111 | const app = fastify(); 112 | 113 | app.register(UnderPressure, { 114 | exposeStatusRoute: '/status/health-check', 115 | healthCheck: () => Promise.resolve(true), 116 | }); 117 | app.setSerializerCompiler(serializerCompiler); 118 | app.post( 119 | '/', 120 | { 121 | schema: { 122 | response: { 123 | 200: { 124 | type: 'object', 125 | properties: { 126 | jobId: { type: 'string' }, 127 | }, 128 | }, 129 | }, 130 | }, 131 | }, 132 | async (_req, res) => 133 | res.send({ 134 | jobId: '60002023', 135 | }), 136 | ); 137 | 138 | await app.ready(); 139 | 140 | const result = await app.inject().get('/status/health-check'); 141 | 142 | expect(result.json()).toEqual({ status: 'ok' }); 143 | }); 144 | 145 | it('should fail an invalid response', async () => { 146 | const app = fastify(); 147 | 148 | app.setSerializerCompiler(serializerCompiler); 149 | app.withTypeProvider().post( 150 | '/', 151 | { 152 | schema: { 153 | response: { 154 | 200: { 155 | content: { 156 | 'application/json': { 157 | schema: z.object({ 158 | jobId: z.string().openapi({ 159 | description: 'Job ID', 160 | example: '60002023', 161 | }), 162 | }), 163 | }, 164 | }, 165 | }, 166 | } satisfies ZodOpenApiResponsesObject, 167 | }, 168 | }, 169 | async (_req, res) => res.send({ jobId: 1 as unknown as string }), 170 | ); 171 | await app.ready(); 172 | 173 | const result = await app.inject().post('/'); 174 | 175 | expect(result.statusCode).toBe(500); 176 | expect(result.json()).toMatchInlineSnapshot(` 177 | { 178 | "code": "FST_ERR_RESPONSE_SERIALIZATION", 179 | "error": "Internal Server Error", 180 | "message": "Response does not match the schema", 181 | "statusCode": 500, 182 | } 183 | `); 184 | }); 185 | 186 | it('should handle Zod effects in the response', async () => { 187 | const app = fastify(); 188 | 189 | app.setSerializerCompiler(serializerCompiler); 190 | app.withTypeProvider().post( 191 | '/', 192 | { 193 | schema: { 194 | response: { 195 | 200: z.object({ 196 | jobId: z.string().default('foo').openapi({ 197 | description: 'Job ID', 198 | example: '60002023', 199 | }), 200 | }), 201 | }, 202 | }, 203 | }, 204 | async (_req, res) => res.send({ jobId: undefined }), 205 | ); 206 | await app.ready(); 207 | 208 | const result = await app.inject().post('/'); 209 | 210 | expect(result.json()).toEqual({ jobId: 'foo' }); 211 | }); 212 | 213 | it('should handle a complex response', async () => { 214 | const app = fastify(); 215 | 216 | app.setSerializerCompiler(serializerCompiler); 217 | app.withTypeProvider().post( 218 | '/', 219 | { 220 | schema: { 221 | response: { 222 | 200: { 223 | content: { 224 | 'application/json': { 225 | schema: z.object({ 226 | // invent a complex schema 227 | jobId: z.string().openapi({ 228 | description: 'Job ID', 229 | example: '60002023', 230 | }), 231 | jobName: z.string().openapi({ 232 | description: 'Job Name', 233 | example: 'Job 1', 234 | }), 235 | jobStatus: z.string().openapi({ 236 | description: 'Job Status', 237 | example: 'completed', 238 | }), 239 | jobDetails: z.object({ 240 | jobType: z.string().openapi({ 241 | description: 'Job Type', 242 | example: 'export', 243 | }), 244 | jobDate: z.string().openapi({ 245 | description: 'Job Date', 246 | example: '2021-09-01', 247 | }), 248 | }), 249 | jobArray: z.array( 250 | z 251 | .object({ 252 | jobType: z.string().openapi({ 253 | description: 'Job Type', 254 | example: 'export', 255 | }), 256 | jobDate: z.string().openapi({ 257 | description: 'Job Date', 258 | example: '2021-09-01', 259 | }), 260 | }) 261 | .openapi({ ref: 'something' }), 262 | ), 263 | jobTuple: z 264 | .tuple([ 265 | z.string().openapi({ ref: 'string' }), 266 | z.number().openapi({ ref: 'number' }), 267 | ]) 268 | .openapi({ 269 | description: 'Job Tuple', 270 | example: ['foo', 123], 271 | }), 272 | metadata: z.discriminatedUnion('type', [ 273 | z 274 | .object({ 275 | type: z.literal('success'), 276 | success: z.string().openapi({ 277 | description: 'Success Message', 278 | example: 'Job completed successfully', 279 | }), 280 | }) 281 | .openapi({ ref: 'success' }), 282 | z 283 | .object({ 284 | type: z.literal('error'), 285 | error: z.string().openapi({ 286 | description: 'Error Message', 287 | example: 'Job failed', 288 | }), 289 | }) 290 | .openapi({ ref: 'error' }), 291 | ]), 292 | }), 293 | }, 294 | }, 295 | }, 296 | } satisfies ZodOpenApiResponsesObject, 297 | }, 298 | }, 299 | async (_req, res) => 300 | res.send({ 301 | jobId: '60002023', 302 | jobName: 'Job 1', 303 | jobStatus: 'completed', 304 | jobDetails: { 305 | jobType: 'export', 306 | jobDate: '2021-09-01', 307 | }, 308 | jobArray: [ 309 | { 310 | jobType: 'export', 311 | jobDate: '2021-09-01', 312 | }, 313 | ], 314 | jobTuple: ['foo', 123], 315 | metadata: { 316 | type: 'success', 317 | success: 'Job completed successfully', 318 | }, 319 | }), 320 | ); 321 | await app.ready(); 322 | 323 | const result = await app.inject().post('/'); 324 | 325 | expect(result.json()).toMatchInlineSnapshot(` 326 | { 327 | "jobArray": [ 328 | { 329 | "jobDate": "2021-09-01", 330 | "jobType": "export", 331 | }, 332 | ], 333 | "jobDetails": { 334 | "jobDate": "2021-09-01", 335 | "jobType": "export", 336 | }, 337 | "jobId": "60002023", 338 | "jobName": "Job 1", 339 | "jobStatus": "completed", 340 | "jobTuple": [ 341 | "foo", 342 | 123, 343 | ], 344 | "metadata": { 345 | "success": "Job completed successfully", 346 | "type": "success", 347 | }, 348 | } 349 | `); 350 | }); 351 | }); 352 | 353 | describe('createSerializerCompiler', () => { 354 | it('should create a custom serializer', async () => { 355 | const app = fastify(); 356 | 357 | const customSerializerCompiler = createSerializerCompiler({ 358 | stringify: JSON.stringify, 359 | }); 360 | app.setSerializerCompiler(customSerializerCompiler); 361 | 362 | app.withTypeProvider().post( 363 | '/', 364 | { 365 | schema: { 366 | response: { 367 | 200: z.object({ 368 | jobId: z.string().openapi({ 369 | description: 'Job ID', 370 | example: '60002023', 371 | }), 372 | }), 373 | }, 374 | }, 375 | }, 376 | async (_req, res) => res.send({ jobId: '123' }), 377 | ); 378 | await app.ready(); 379 | 380 | const result = await app.inject().post('/'); 381 | 382 | expect(result.json()).toEqual({ jobId: '123' }); 383 | }); 384 | 385 | it('should support custom components', async () => { 386 | const app = fastify(); 387 | 388 | const jobId = z.string().openapi({ 389 | description: 'Job ID', 390 | example: '60002023', 391 | }); 392 | const customSerializerCompiler = createSerializerCompiler({ 393 | components: { 394 | jobId, 395 | }, 396 | }); 397 | app.setSerializerCompiler(customSerializerCompiler); 398 | 399 | app.withTypeProvider().post( 400 | '/', 401 | { 402 | schema: { 403 | response: { 404 | 200: z.object({ 405 | jobId, 406 | }), 407 | }, 408 | }, 409 | }, 410 | async (_req, res) => res.send({ jobId: '123' }), 411 | ); 412 | await app.ready(); 413 | 414 | const result = await app.inject().post('/'); 415 | 416 | expect(result.json()).toEqual({ jobId: '123' }); 417 | }); 418 | }); 419 | 420 | describe('setErrorHandler', () => { 421 | it('should handle ResponseSerializationError errors', async () => { 422 | const app = fastify(); 423 | 424 | app.setSerializerCompiler(serializerCompiler); 425 | app.setErrorHandler((error, _req, res) => { 426 | if (error instanceof ResponseSerializationError) { 427 | return res.status(500).send({ 428 | error: 'Bad response', 429 | }); 430 | } 431 | return res.status(500).send({ 432 | error: 'Unknown error', 433 | }); 434 | }); 435 | 436 | app.withTypeProvider().post( 437 | '/', 438 | { 439 | schema: { 440 | response: { 441 | 200: z.object({ 442 | jobId: z.string().openapi({ 443 | description: 'Job ID', 444 | example: '60002023', 445 | }), 446 | }), 447 | }, 448 | }, 449 | }, 450 | async (_req, res) => 451 | res.send({ a: 'bad' } as unknown as { jobId: string }), 452 | ); 453 | await app.ready(); 454 | 455 | const result = await app.inject().post('/'); 456 | expect(result.statusCode).toBe(500); 457 | expect(result.json()).toEqual({ error: 'Bad response' }); 458 | }); 459 | }); 460 | -------------------------------------------------------------------------------- /src/serializerCompiler.ts: -------------------------------------------------------------------------------- 1 | import fastJsonStringify, { 2 | type ObjectSchema, 3 | type Schema, 4 | } from 'fast-json-stringify'; 5 | import type { FastifySerializerCompiler } from 'fastify/types/schema'; 6 | import type { ZodType, ZodTypeAny } from 'zod'; 7 | import { createSchema } from 'zod-openapi'; 8 | 9 | import { isZodType } from './transformer'; 10 | import { ResponseSerializationError } from './validationError'; 11 | 12 | export interface SerializerOptions { 13 | components?: Record; 14 | stringify?: (value: unknown) => string; 15 | fallbackSerializer?: FastifySerializerCompiler; 16 | } 17 | 18 | export const createSerializerCompiler = 19 | (opts?: SerializerOptions): FastifySerializerCompiler => 20 | (routeSchema) => { 21 | const { schema, url, method } = routeSchema; 22 | if (!isZodType(schema)) { 23 | return opts?.fallbackSerializer 24 | ? opts.fallbackSerializer(routeSchema) 25 | : fastJsonStringify(schema); 26 | } 27 | 28 | let stringify = opts?.stringify; 29 | if (!stringify) { 30 | const { schema: jsonSchema, components } = createSchema(schema, { 31 | components: opts?.components, 32 | componentRefPath: '#/definitions/', 33 | }); 34 | 35 | const maybeDefinitions: Pick | undefined = 36 | components 37 | ? { 38 | definitions: components as Record, 39 | } 40 | : undefined; 41 | 42 | stringify = fastJsonStringify({ 43 | ...(jsonSchema as Schema), 44 | ...maybeDefinitions, 45 | }); 46 | } 47 | 48 | return (value) => { 49 | const result = schema.safeParse(value); 50 | 51 | if (!result.success) { 52 | throw new ResponseSerializationError(method, url, { 53 | cause: result.error, 54 | }); 55 | } 56 | 57 | return stringify(result.data); 58 | }; 59 | }; 60 | 61 | /** 62 | * Enables zod-openapi schema response validation 63 | * 64 | * @example 65 | * ```typescript 66 | * import Fastify from 'fastify' 67 | * 68 | * const server = Fastify().setserializerCompiler(serializerCompiler) 69 | * ``` 70 | */ 71 | export const serializerCompiler = createSerializerCompiler(); 72 | -------------------------------------------------------------------------------- /src/transformer.test.ts: -------------------------------------------------------------------------------- 1 | import 'zod-openapi/extend'; 2 | import fastifySwagger from '@fastify/swagger'; 3 | import fastifySwaggerUI from '@fastify/swagger-ui'; 4 | import fastify from 'fastify'; 5 | import { z } from 'zod'; 6 | 7 | import { 8 | type FastifyZodOpenApiTypeProvider, 9 | serializerCompiler, 10 | validatorCompiler, 11 | } from '../src'; 12 | import { fastifyZodOpenApiPlugin } from '../src/plugin'; 13 | import { 14 | type FastifyZodOpenApiSchema, 15 | fastifyZodOpenApiTransform, 16 | fastifyZodOpenApiTransformObject, 17 | } from '../src/transformer'; 18 | 19 | describe('fastifyZodOpenApiTransform', () => { 20 | it('should support creating an openapi response', async () => { 21 | const app = fastify(); 22 | 23 | app.setSerializerCompiler(serializerCompiler); 24 | 25 | await app.register(fastifyZodOpenApiPlugin); 26 | await app.register(fastifySwagger, { 27 | openapi: { 28 | info: { 29 | title: 'hello world', 30 | version: '1.0.0', 31 | }, 32 | openapi: '3.1.0', 33 | }, 34 | transform: fastifyZodOpenApiTransform, 35 | }); 36 | await app.register(fastifySwaggerUI, { 37 | routePrefix: '/documentation', 38 | }); 39 | 40 | app.withTypeProvider().post( 41 | '/', 42 | { 43 | schema: { 44 | response: { 45 | 200: { 46 | content: { 47 | 'application/json': { 48 | schema: z.object({ 49 | jobId: z.string().openapi({ 50 | description: 'Job ID', 51 | example: '60002023', 52 | }), 53 | }), 54 | }, 55 | }, 56 | }, 57 | }, 58 | } satisfies FastifyZodOpenApiSchema, 59 | }, 60 | async (_req, res) => 61 | res.send({ 62 | jobId: '60002023', 63 | }), 64 | ); 65 | await app.ready(); 66 | 67 | const result = await app.inject().get('/documentation/json'); 68 | 69 | expect(result.json()).toMatchInlineSnapshot(` 70 | { 71 | "components": { 72 | "schemas": {}, 73 | }, 74 | "info": { 75 | "title": "hello world", 76 | "version": "1.0.0", 77 | }, 78 | "openapi": "3.1.0", 79 | "paths": { 80 | "/": { 81 | "post": { 82 | "responses": { 83 | "200": { 84 | "content": { 85 | "application/json": { 86 | "schema": { 87 | "properties": { 88 | "jobId": { 89 | "description": "Job ID", 90 | "example": "60002023", 91 | "type": "string", 92 | }, 93 | }, 94 | "required": [ 95 | "jobId", 96 | ], 97 | "type": "object", 98 | }, 99 | }, 100 | }, 101 | "description": "Default Response", 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | } 108 | `); 109 | }); 110 | 111 | it('should support creating a shortcut openapi response', async () => { 112 | const app = fastify(); 113 | 114 | app.setSerializerCompiler(serializerCompiler); 115 | 116 | await app.register(fastifyZodOpenApiPlugin); 117 | await app.register(fastifySwagger, { 118 | openapi: { 119 | info: { 120 | title: 'hello world', 121 | version: '1.0.0', 122 | }, 123 | openapi: '3.1.0', 124 | }, 125 | transform: fastifyZodOpenApiTransform, 126 | }); 127 | await app.register(fastifySwaggerUI, { 128 | routePrefix: '/documentation', 129 | }); 130 | 131 | app.withTypeProvider().post( 132 | '/', 133 | { 134 | schema: { 135 | response: { 136 | 200: z.object({ 137 | jobId: z.string().openapi({ 138 | description: 'Job ID', 139 | example: '60002023', 140 | }), 141 | }), 142 | }, 143 | } satisfies FastifyZodOpenApiSchema, 144 | }, 145 | async (_req, res) => 146 | res.send({ 147 | jobId: '60002023', 148 | }), 149 | ); 150 | await app.ready(); 151 | 152 | const result = await app.inject().get('/documentation/json'); 153 | 154 | expect(result.json()).toMatchInlineSnapshot(` 155 | { 156 | "components": { 157 | "schemas": {}, 158 | }, 159 | "info": { 160 | "title": "hello world", 161 | "version": "1.0.0", 162 | }, 163 | "openapi": "3.1.0", 164 | "paths": { 165 | "/": { 166 | "post": { 167 | "responses": { 168 | "200": { 169 | "content": { 170 | "application/json": { 171 | "schema": { 172 | "properties": { 173 | "jobId": { 174 | "description": "Job ID", 175 | "example": "60002023", 176 | "type": "string", 177 | }, 178 | }, 179 | "required": [ 180 | "jobId", 181 | ], 182 | "type": "object", 183 | }, 184 | }, 185 | }, 186 | "description": "Default Response", 187 | }, 188 | }, 189 | }, 190 | }, 191 | }, 192 | } 193 | `); 194 | }); 195 | 196 | it('should support creating an openapi body', async () => { 197 | const app = fastify(); 198 | 199 | app.setValidatorCompiler(validatorCompiler); 200 | 201 | await app.register(fastifyZodOpenApiPlugin); 202 | await app.register(fastifySwagger, { 203 | openapi: { 204 | info: { 205 | title: 'hello world', 206 | version: '1.0.0', 207 | }, 208 | openapi: '3.1.0', 209 | }, 210 | transform: fastifyZodOpenApiTransform, 211 | }); 212 | await app.register(fastifySwaggerUI, { 213 | routePrefix: '/documentation', 214 | }); 215 | 216 | app.withTypeProvider().post( 217 | '/', 218 | { 219 | schema: { 220 | body: z.object({ 221 | jobId: z.string().openapi({ 222 | description: 'Job ID', 223 | example: '60002023', 224 | }), 225 | }), 226 | } satisfies FastifyZodOpenApiSchema, 227 | }, 228 | async (_req, res) => 229 | res.send({ 230 | jobId: '60002023', 231 | }), 232 | ); 233 | await app.ready(); 234 | 235 | const result = await app.inject().get('/documentation/json'); 236 | 237 | expect(result.json()).toMatchInlineSnapshot(` 238 | { 239 | "components": { 240 | "schemas": {}, 241 | }, 242 | "info": { 243 | "title": "hello world", 244 | "version": "1.0.0", 245 | }, 246 | "openapi": "3.1.0", 247 | "paths": { 248 | "/": { 249 | "post": { 250 | "requestBody": { 251 | "content": { 252 | "application/json": { 253 | "schema": { 254 | "properties": { 255 | "jobId": { 256 | "description": "Job ID", 257 | "example": "60002023", 258 | "type": "string", 259 | }, 260 | }, 261 | "required": [ 262 | "jobId", 263 | ], 264 | "type": "object", 265 | }, 266 | }, 267 | }, 268 | "required": true, 269 | }, 270 | "responses": { 271 | "200": { 272 | "description": "Default Response", 273 | }, 274 | }, 275 | }, 276 | }, 277 | }, 278 | } 279 | `); 280 | }); 281 | 282 | it('should support creating an openapi union body', async () => { 283 | const app = fastify(); 284 | 285 | app.setValidatorCompiler(validatorCompiler); 286 | 287 | await app.register(fastifyZodOpenApiPlugin); 288 | await app.register(fastifySwagger, { 289 | openapi: { 290 | info: { 291 | title: 'hello world', 292 | version: '1.0.0', 293 | }, 294 | openapi: '3.1.0', 295 | }, 296 | transform: fastifyZodOpenApiTransform, 297 | }); 298 | await app.register(fastifySwaggerUI, { 299 | routePrefix: '/documentation', 300 | }); 301 | 302 | app.withTypeProvider().post( 303 | '/', 304 | { 305 | schema: { 306 | body: z.union([ 307 | z.object({ 308 | jobId: z.string().openapi({ 309 | description: 'Job ID', 310 | example: '60002023', 311 | }), 312 | }), 313 | z.object({ 314 | jobId: z.number().openapi({ 315 | description: 'Job ID', 316 | example: 60002023, 317 | }), 318 | }), 319 | ]), 320 | }, 321 | }, 322 | async (_req, res) => 323 | res.send({ 324 | jobId: '60002023', 325 | }), 326 | ); 327 | await app.ready(); 328 | 329 | const result = await app.inject().get('/documentation/json'); 330 | 331 | expect(result.json()).toMatchInlineSnapshot(` 332 | { 333 | "components": { 334 | "schemas": {}, 335 | }, 336 | "info": { 337 | "title": "hello world", 338 | "version": "1.0.0", 339 | }, 340 | "openapi": "3.1.0", 341 | "paths": { 342 | "/": { 343 | "post": { 344 | "requestBody": { 345 | "content": { 346 | "application/json": { 347 | "schema": { 348 | "anyOf": [ 349 | { 350 | "properties": { 351 | "jobId": { 352 | "description": "Job ID", 353 | "example": "60002023", 354 | "type": "string", 355 | }, 356 | }, 357 | "required": [ 358 | "jobId", 359 | ], 360 | "type": "object", 361 | }, 362 | { 363 | "properties": { 364 | "jobId": { 365 | "description": "Job ID", 366 | "example": 60002023, 367 | "type": "number", 368 | }, 369 | }, 370 | "required": [ 371 | "jobId", 372 | ], 373 | "type": "object", 374 | }, 375 | ], 376 | }, 377 | }, 378 | }, 379 | }, 380 | "responses": { 381 | "200": { 382 | "description": "Default Response", 383 | }, 384 | }, 385 | }, 386 | }, 387 | }, 388 | } 389 | `); 390 | }); 391 | 392 | it('should support creating an openapi array body', async () => { 393 | const app = fastify(); 394 | 395 | app.setValidatorCompiler(validatorCompiler); 396 | 397 | await app.register(fastifyZodOpenApiPlugin); 398 | await app.register(fastifySwagger, { 399 | openapi: { 400 | info: { 401 | title: 'hello world', 402 | version: '1.0.0', 403 | }, 404 | openapi: '3.1.0', 405 | }, 406 | transform: fastifyZodOpenApiTransform, 407 | }); 408 | await app.register(fastifySwaggerUI, { 409 | routePrefix: '/documentation', 410 | }); 411 | 412 | app.withTypeProvider().post( 413 | '/', 414 | { 415 | schema: { 416 | body: z.array( 417 | z.string().openapi({ 418 | description: 'Job ID', 419 | example: '60002023', 420 | }), 421 | ), 422 | } satisfies FastifyZodOpenApiSchema, 423 | }, 424 | async (_req, res) => res.send(['60002023']), 425 | ); 426 | await app.ready(); 427 | 428 | const result = await app.inject().get('/documentation/json'); 429 | 430 | expect(result.json()).toMatchInlineSnapshot(` 431 | { 432 | "components": { 433 | "schemas": {}, 434 | }, 435 | "info": { 436 | "title": "hello world", 437 | "version": "1.0.0", 438 | }, 439 | "openapi": "3.1.0", 440 | "paths": { 441 | "/": { 442 | "post": { 443 | "requestBody": { 444 | "content": { 445 | "application/json": { 446 | "schema": { 447 | "items": { 448 | "description": "Job ID", 449 | "example": "60002023", 450 | "type": "string", 451 | }, 452 | "type": "array", 453 | }, 454 | }, 455 | }, 456 | }, 457 | "responses": { 458 | "200": { 459 | "description": "Default Response", 460 | }, 461 | }, 462 | }, 463 | }, 464 | }, 465 | } 466 | `); 467 | }); 468 | 469 | it('should support creating an openapi path parameter', async () => { 470 | const app = fastify(); 471 | 472 | app.setValidatorCompiler(validatorCompiler); 473 | 474 | await app.register(fastifyZodOpenApiPlugin); 475 | await app.register(fastifySwagger, { 476 | openapi: { 477 | info: { 478 | title: 'hello world', 479 | version: '1.0.0', 480 | }, 481 | openapi: '3.1.0', 482 | }, 483 | transform: fastifyZodOpenApiTransform, 484 | }); 485 | await app.register(fastifySwaggerUI, { 486 | routePrefix: '/documentation', 487 | }); 488 | 489 | app.withTypeProvider().post( 490 | '/', 491 | { 492 | schema: { 493 | params: z.object({ 494 | jobId: z.string().openapi({ 495 | description: 'Job ID', 496 | example: '60002023', 497 | }), 498 | }), 499 | } satisfies FastifyZodOpenApiSchema, 500 | }, 501 | async (_req, res) => 502 | res.send({ 503 | jobId: '60002023', 504 | }), 505 | ); 506 | await app.ready(); 507 | 508 | const result = await app.inject().get('/documentation/json'); 509 | 510 | expect(result.json()).toMatchInlineSnapshot(` 511 | { 512 | "components": { 513 | "schemas": {}, 514 | }, 515 | "info": { 516 | "title": "hello world", 517 | "version": "1.0.0", 518 | }, 519 | "openapi": "3.1.0", 520 | "paths": { 521 | "/": { 522 | "post": { 523 | "parameters": [ 524 | { 525 | "description": "Job ID", 526 | "in": "path", 527 | "name": "jobId", 528 | "required": true, 529 | "schema": { 530 | "example": "60002023", 531 | "type": "string", 532 | }, 533 | }, 534 | ], 535 | "responses": { 536 | "200": { 537 | "description": "Default Response", 538 | }, 539 | }, 540 | }, 541 | }, 542 | }, 543 | } 544 | `); 545 | }); 546 | 547 | it('should support creating an openapi query parameter', async () => { 548 | const app = fastify(); 549 | 550 | app.setValidatorCompiler(validatorCompiler); 551 | 552 | await app.register(fastifyZodOpenApiPlugin); 553 | await app.register(fastifySwagger, { 554 | openapi: { 555 | info: { 556 | title: 'hello world', 557 | version: '1.0.0', 558 | }, 559 | openapi: '3.1.0', 560 | }, 561 | transform: fastifyZodOpenApiTransform, 562 | }); 563 | await app.register(fastifySwaggerUI, { 564 | routePrefix: '/documentation', 565 | }); 566 | 567 | app.withTypeProvider().post( 568 | '/', 569 | { 570 | schema: { 571 | querystring: z.object({ 572 | jobId: z.string().openapi({ 573 | description: 'Job ID', 574 | example: '60002023', 575 | }), 576 | }), 577 | } satisfies FastifyZodOpenApiSchema, 578 | }, 579 | async (_req, res) => 580 | res.send({ 581 | jobId: '60002023', 582 | }), 583 | ); 584 | await app.ready(); 585 | 586 | const result = await app.inject().get('/documentation/json'); 587 | 588 | expect(result.json()).toMatchInlineSnapshot(` 589 | { 590 | "components": { 591 | "schemas": {}, 592 | }, 593 | "info": { 594 | "title": "hello world", 595 | "version": "1.0.0", 596 | }, 597 | "openapi": "3.1.0", 598 | "paths": { 599 | "/": { 600 | "post": { 601 | "parameters": [ 602 | { 603 | "description": "Job ID", 604 | "in": "query", 605 | "name": "jobId", 606 | "required": true, 607 | "schema": { 608 | "example": "60002023", 609 | "type": "string", 610 | }, 611 | }, 612 | ], 613 | "responses": { 614 | "200": { 615 | "description": "Default Response", 616 | }, 617 | }, 618 | }, 619 | }, 620 | }, 621 | } 622 | `); 623 | }); 624 | 625 | it('should support creating parameters using Zod Effects', async () => { 626 | const app = fastify(); 627 | 628 | app.setValidatorCompiler(validatorCompiler); 629 | 630 | await app.register(fastifyZodOpenApiPlugin); 631 | await app.register(fastifySwagger, { 632 | openapi: { 633 | info: { 634 | title: 'hello world', 635 | version: '1.0.0', 636 | }, 637 | openapi: '3.1.0', 638 | }, 639 | transform: fastifyZodOpenApiTransform, 640 | }); 641 | await app.register(fastifySwaggerUI, { 642 | routePrefix: '/documentation', 643 | }); 644 | 645 | app.withTypeProvider().post( 646 | '/', 647 | { 648 | schema: { 649 | body: z 650 | .object({ 651 | jobId: z.string().openapi({ 652 | description: 'Job ID', 653 | example: '60002023', 654 | }), 655 | }) 656 | .refine(() => true), 657 | querystring: z 658 | .object({ 659 | jobId: z.string().openapi({ 660 | description: 'Job ID', 661 | example: '60002023', 662 | }), 663 | }) 664 | .refine(() => true), 665 | params: z 666 | .object({ 667 | jobId: z.string().openapi({ 668 | description: 'Job ID', 669 | example: '60002023', 670 | }), 671 | }) 672 | .refine(() => true), 673 | headers: z 674 | .object({ 675 | jobId: z.string().openapi({ 676 | description: 'Job ID', 677 | example: '60002023', 678 | }), 679 | }) 680 | .refine(() => true), 681 | } satisfies FastifyZodOpenApiSchema, 682 | }, 683 | async (_req, res) => 684 | res.send({ 685 | jobId: '60002023', 686 | }), 687 | ); 688 | await app.ready(); 689 | 690 | const result = await app.inject().get('/documentation/json'); 691 | 692 | expect(result.json()).toMatchInlineSnapshot(` 693 | { 694 | "components": { 695 | "schemas": {}, 696 | }, 697 | "info": { 698 | "title": "hello world", 699 | "version": "1.0.0", 700 | }, 701 | "openapi": "3.1.0", 702 | "paths": { 703 | "/": { 704 | "post": { 705 | "parameters": [ 706 | { 707 | "description": "Job ID", 708 | "in": "query", 709 | "name": "jobId", 710 | "required": true, 711 | "schema": { 712 | "example": "60002023", 713 | "type": "string", 714 | }, 715 | }, 716 | { 717 | "description": "Job ID", 718 | "in": "path", 719 | "name": "jobId", 720 | "required": true, 721 | "schema": { 722 | "example": "60002023", 723 | "type": "string", 724 | }, 725 | }, 726 | { 727 | "description": "Job ID", 728 | "in": "header", 729 | "name": "jobId", 730 | "required": true, 731 | "schema": { 732 | "example": "60002023", 733 | "type": "string", 734 | }, 735 | }, 736 | ], 737 | "requestBody": { 738 | "content": { 739 | "application/json": { 740 | "schema": { 741 | "properties": { 742 | "jobId": { 743 | "description": "Job ID", 744 | "example": "60002023", 745 | "type": "string", 746 | }, 747 | }, 748 | "required": [ 749 | "jobId", 750 | ], 751 | "type": "object", 752 | }, 753 | }, 754 | }, 755 | "required": true, 756 | }, 757 | "responses": { 758 | "200": { 759 | "description": "Default Response", 760 | }, 761 | }, 762 | }, 763 | }, 764 | }, 765 | } 766 | `); 767 | }); 768 | 769 | it('should support creating an openapi header parameter', async () => { 770 | const app = fastify(); 771 | 772 | app.setValidatorCompiler(validatorCompiler); 773 | 774 | await app.register(fastifyZodOpenApiPlugin); 775 | await app.register(fastifySwagger, { 776 | openapi: { 777 | info: { 778 | title: 'hello world', 779 | version: '1.0.0', 780 | }, 781 | openapi: '3.1.0', 782 | }, 783 | transform: fastifyZodOpenApiTransform, 784 | }); 785 | await app.register(fastifySwaggerUI, { 786 | routePrefix: '/documentation', 787 | }); 788 | 789 | app.withTypeProvider().post( 790 | '/', 791 | { 792 | schema: { 793 | headers: z.object({ 794 | jobId: z.string().openapi({ 795 | description: 'Job ID', 796 | example: '60002023', 797 | }), 798 | }), 799 | } satisfies FastifyZodOpenApiSchema, 800 | }, 801 | async (_req, res) => 802 | res.send({ 803 | jobId: '60002023', 804 | }), 805 | ); 806 | await app.ready(); 807 | 808 | const result = await app.inject().get('/documentation/json'); 809 | 810 | expect(result.json()).toMatchInlineSnapshot(` 811 | { 812 | "components": { 813 | "schemas": {}, 814 | }, 815 | "info": { 816 | "title": "hello world", 817 | "version": "1.0.0", 818 | }, 819 | "openapi": "3.1.0", 820 | "paths": { 821 | "/": { 822 | "post": { 823 | "parameters": [ 824 | { 825 | "description": "Job ID", 826 | "in": "header", 827 | "name": "jobId", 828 | "required": true, 829 | "schema": { 830 | "example": "60002023", 831 | "type": "string", 832 | }, 833 | }, 834 | ], 835 | "responses": { 836 | "200": { 837 | "description": "Default Response", 838 | }, 839 | }, 840 | }, 841 | }, 842 | }, 843 | } 844 | `); 845 | }); 846 | }); 847 | 848 | describe('fastifyZodOpenApiTransformObject', () => { 849 | it('should support creating components using ref key', async () => { 850 | const app = fastify(); 851 | 852 | app.setSerializerCompiler(serializerCompiler); 853 | 854 | await app.register(fastifyZodOpenApiPlugin); 855 | await app.register(fastifySwagger, { 856 | openapi: { 857 | info: { 858 | title: 'hello world', 859 | version: '1.0.0', 860 | }, 861 | openapi: '3.1.0', 862 | }, 863 | transform: fastifyZodOpenApiTransform, 864 | transformObject: fastifyZodOpenApiTransformObject, 865 | }); 866 | await app.register(fastifySwaggerUI, { 867 | routePrefix: '/documentation', 868 | }); 869 | 870 | app.withTypeProvider().post( 871 | '/', 872 | { 873 | schema: { 874 | response: { 875 | 200: { 876 | content: { 877 | 'application/json': { 878 | schema: z.object({ 879 | jobId: z.string().openapi({ 880 | description: 'Job ID', 881 | example: '60002023', 882 | ref: 'jobId', 883 | }), 884 | }), 885 | }, 886 | }, 887 | }, 888 | }, 889 | } satisfies FastifyZodOpenApiSchema, 890 | }, 891 | async (_req, res) => 892 | res.send({ 893 | jobId: '60002023', 894 | }), 895 | ); 896 | await app.ready(); 897 | 898 | const result = await app.inject().get('/documentation/json'); 899 | 900 | expect(result.json()).toMatchInlineSnapshot(` 901 | { 902 | "components": { 903 | "schemas": { 904 | "jobId": { 905 | "description": "Job ID", 906 | "example": "60002023", 907 | "type": "string", 908 | }, 909 | }, 910 | }, 911 | "info": { 912 | "title": "hello world", 913 | "version": "1.0.0", 914 | }, 915 | "openapi": "3.1.0", 916 | "paths": { 917 | "/": { 918 | "post": { 919 | "responses": { 920 | "200": { 921 | "content": { 922 | "application/json": { 923 | "schema": { 924 | "properties": { 925 | "jobId": { 926 | "$ref": "#/components/schemas/jobId", 927 | }, 928 | }, 929 | "required": [ 930 | "jobId", 931 | ], 932 | "type": "object", 933 | }, 934 | }, 935 | }, 936 | "description": "Default Response", 937 | }, 938 | }, 939 | }, 940 | }, 941 | }, 942 | } 943 | `); 944 | }); 945 | 946 | it('should support creating components using components option', async () => { 947 | const app = fastify(); 948 | 949 | app.setSerializerCompiler(serializerCompiler); 950 | 951 | const jobId = z.string().openapi({ 952 | description: 'Job ID', 953 | example: '60002023', 954 | ref: 'jobId', 955 | }); 956 | 957 | await app.register(fastifyZodOpenApiPlugin, { 958 | components: { schemas: { jobId } }, 959 | }); 960 | await app.register(fastifySwagger, { 961 | openapi: { 962 | info: { 963 | title: 'hello world', 964 | version: '1.0.0', 965 | }, 966 | openapi: '3.1.0', 967 | }, 968 | transform: fastifyZodOpenApiTransform, 969 | transformObject: fastifyZodOpenApiTransformObject, 970 | }); 971 | await app.register(fastifySwaggerUI, { 972 | routePrefix: '/documentation', 973 | }); 974 | 975 | app.withTypeProvider().post( 976 | '/', 977 | { 978 | schema: { 979 | response: { 980 | 200: { 981 | content: { 982 | 'application/json': { 983 | schema: z.object({ 984 | jobId, 985 | }), 986 | }, 987 | }, 988 | }, 989 | }, 990 | } satisfies FastifyZodOpenApiSchema, 991 | }, 992 | async (_req, res) => 993 | res.send({ 994 | jobId: '60002023', 995 | }), 996 | ); 997 | await app.ready(); 998 | 999 | const result = await app.inject().get('/documentation/json'); 1000 | 1001 | expect(result.json()).toMatchInlineSnapshot(` 1002 | { 1003 | "components": { 1004 | "schemas": { 1005 | "jobId": { 1006 | "description": "Job ID", 1007 | "example": "60002023", 1008 | "type": "string", 1009 | }, 1010 | }, 1011 | }, 1012 | "info": { 1013 | "title": "hello world", 1014 | "version": "1.0.0", 1015 | }, 1016 | "openapi": "3.1.0", 1017 | "paths": { 1018 | "/": { 1019 | "post": { 1020 | "responses": { 1021 | "200": { 1022 | "content": { 1023 | "application/json": { 1024 | "schema": { 1025 | "properties": { 1026 | "jobId": { 1027 | "$ref": "#/components/schemas/jobId", 1028 | }, 1029 | }, 1030 | "required": [ 1031 | "jobId", 1032 | ], 1033 | "type": "object", 1034 | }, 1035 | }, 1036 | }, 1037 | "description": "Default Response", 1038 | }, 1039 | }, 1040 | }, 1041 | }, 1042 | }, 1043 | } 1044 | `); 1045 | }); 1046 | 1047 | it('should support setting a custom openapi version', async () => { 1048 | const app = fastify(); 1049 | 1050 | app.setSerializerCompiler(serializerCompiler); 1051 | 1052 | const jobId = z.string().nullable().openapi({ 1053 | description: 'Job ID', 1054 | example: '60002023', 1055 | ref: 'jobId', 1056 | }); 1057 | 1058 | await app.register(fastifyZodOpenApiPlugin, { 1059 | components: { schemas: { jobId } }, 1060 | }); 1061 | await app.register(fastifySwagger, { 1062 | openapi: { 1063 | info: { 1064 | title: 'hello world', 1065 | version: '1.0.0', 1066 | }, 1067 | openapi: '3.0.3', 1068 | }, 1069 | transform: fastifyZodOpenApiTransform, 1070 | transformObject: fastifyZodOpenApiTransformObject, 1071 | }); 1072 | await app.register(fastifySwaggerUI, { 1073 | routePrefix: '/documentation', 1074 | }); 1075 | 1076 | app.withTypeProvider().post( 1077 | '/', 1078 | { 1079 | schema: { 1080 | response: { 1081 | 200: { 1082 | content: { 1083 | 'application/json': { 1084 | schema: z.object({ 1085 | jobId, 1086 | }), 1087 | }, 1088 | }, 1089 | }, 1090 | }, 1091 | } satisfies FastifyZodOpenApiSchema, 1092 | }, 1093 | async (_req, res) => 1094 | res.send({ 1095 | jobId: '60002023', 1096 | }), 1097 | ); 1098 | await app.ready(); 1099 | 1100 | const result = await app.inject().get('/documentation/json'); 1101 | 1102 | expect(result.json()).toMatchInlineSnapshot(` 1103 | { 1104 | "components": { 1105 | "schemas": { 1106 | "jobId": { 1107 | "description": "Job ID", 1108 | "example": "60002023", 1109 | "nullable": true, 1110 | "type": "string", 1111 | }, 1112 | }, 1113 | }, 1114 | "info": { 1115 | "title": "hello world", 1116 | "version": "1.0.0", 1117 | }, 1118 | "openapi": "3.0.3", 1119 | "paths": { 1120 | "/": { 1121 | "post": { 1122 | "responses": { 1123 | "200": { 1124 | "content": { 1125 | "application/json": { 1126 | "schema": { 1127 | "properties": { 1128 | "jobId": { 1129 | "$ref": "#/components/schemas/jobId", 1130 | }, 1131 | }, 1132 | "required": [ 1133 | "jobId", 1134 | ], 1135 | "type": "object", 1136 | }, 1137 | }, 1138 | }, 1139 | "description": "Default Response", 1140 | }, 1141 | }, 1142 | }, 1143 | }, 1144 | }, 1145 | } 1146 | `); 1147 | }); 1148 | 1149 | it('should support create document options', async () => { 1150 | const app = fastify(); 1151 | 1152 | app.setSerializerCompiler(serializerCompiler); 1153 | 1154 | await app.register(fastifyZodOpenApiPlugin, { 1155 | documentOpts: { 1156 | unionOneOf: true, 1157 | }, 1158 | }); 1159 | await app.register(fastifySwagger, { 1160 | openapi: { 1161 | info: { 1162 | title: 'hello world', 1163 | version: '1.0.0', 1164 | }, 1165 | openapi: '3.0.3', 1166 | }, 1167 | transform: fastifyZodOpenApiTransform, 1168 | transformObject: fastifyZodOpenApiTransformObject, 1169 | }); 1170 | await app.register(fastifySwaggerUI, { 1171 | routePrefix: '/documentation', 1172 | }); 1173 | 1174 | app.withTypeProvider().post( 1175 | '/', 1176 | { 1177 | schema: { 1178 | response: { 1179 | 200: { 1180 | content: { 1181 | 'application/json': { 1182 | schema: z.union([z.string(), z.number()]), 1183 | }, 1184 | }, 1185 | }, 1186 | }, 1187 | } satisfies FastifyZodOpenApiSchema, 1188 | }, 1189 | async (_req, res) => res.send('foo'), 1190 | ); 1191 | await app.ready(); 1192 | 1193 | const result = await app.inject().get('/documentation/json'); 1194 | 1195 | expect(result.json()).toMatchInlineSnapshot(` 1196 | { 1197 | "info": { 1198 | "title": "hello world", 1199 | "version": "1.0.0", 1200 | }, 1201 | "openapi": "3.0.3", 1202 | "paths": { 1203 | "/": { 1204 | "post": { 1205 | "responses": { 1206 | "200": { 1207 | "content": { 1208 | "application/json": { 1209 | "schema": { 1210 | "oneOf": [ 1211 | { 1212 | "type": "string", 1213 | }, 1214 | { 1215 | "type": "number", 1216 | }, 1217 | ], 1218 | }, 1219 | }, 1220 | }, 1221 | "description": "Default Response", 1222 | }, 1223 | }, 1224 | }, 1225 | }, 1226 | }, 1227 | } 1228 | `); 1229 | }); 1230 | }); 1231 | -------------------------------------------------------------------------------- /src/transformer.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyDynamicSwaggerOptions } from '@fastify/swagger'; 2 | import type { FastifySchema } from 'fastify'; 3 | import type { OpenAPIV3 } from 'openapi-types'; 4 | import type { ZodObject, ZodRawShape, ZodType } from 'zod'; 5 | import type { 6 | CreateDocumentOptions, 7 | ZodObjectInputType, 8 | ZodOpenApiComponentsObject, 9 | ZodOpenApiParameters, 10 | ZodOpenApiResponsesObject, 11 | ZodOpenApiVersion, 12 | oas31, 13 | } from 'zod-openapi'; 14 | import { 15 | type ComponentsObject, 16 | createComponents, 17 | createMediaTypeSchema, 18 | createParamOrRef, 19 | getZodObject, 20 | } from 'zod-openapi/api'; 21 | 22 | import { 23 | FASTIFY_ZOD_OPENAPI_COMPONENTS, 24 | FASTIFY_ZOD_OPENAPI_CONFIG, 25 | } from './plugin'; 26 | 27 | type Transform = NonNullable; 28 | 29 | type TransformObject = NonNullable< 30 | FastifyDynamicSwaggerOptions['transformObject'] 31 | >; 32 | 33 | type FastifyResponseSchema = ZodType | Record; 34 | 35 | type FastifySwaggerSchemaObject = Omit & { 36 | required?: string[] | boolean; 37 | }; 38 | 39 | export type FastifyZodOpenApiSchema = Omit< 40 | FastifySchema, 41 | 'response' | 'headers' | 'querystring' | 'body' | 'params' 42 | > & { 43 | response?: ZodOpenApiResponsesObject; 44 | headers?: ZodObjectInputType; 45 | querystring?: ZodObjectInputType; 46 | body?: ZodType; 47 | params?: ZodObjectInputType; 48 | }; 49 | 50 | export const isZodType = (object: unknown): object is ZodType => 51 | Boolean( 52 | object && 53 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 54 | Object.getPrototypeOf((object as ZodType)?.constructor)?.name === 55 | 'ZodType', 56 | ); 57 | 58 | export const createParams = ( 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | querystring: ZodObject, 61 | type: keyof ZodOpenApiParameters, 62 | components: ComponentsObject, 63 | path: string[], 64 | doucmentOpts?: CreateDocumentOptions, 65 | ): Record => 66 | Object.entries(querystring.shape as ZodRawShape).reduce( 67 | (acc, [key, value]: [string, ZodType]) => { 68 | const parameter = createParamOrRef( 69 | value, 70 | components, 71 | [...path, key], 72 | type, 73 | key, 74 | doucmentOpts, 75 | ); 76 | 77 | if ('$ref' in parameter || !parameter.schema) { 78 | throw new Error('References not supported'); 79 | } 80 | 81 | acc[key] = { 82 | ...parameter.schema, 83 | ...(parameter.required && { required: true }), 84 | }; 85 | 86 | return acc; 87 | }, 88 | {} as Record, 89 | ); 90 | 91 | export const createResponseSchema = ( 92 | schema: FastifyResponseSchema, 93 | components: ComponentsObject, 94 | path: string[], 95 | documentOpts?: CreateDocumentOptions, 96 | ): unknown => { 97 | if (isZodType(schema)) { 98 | return createMediaTypeSchema( 99 | schema, 100 | components, 101 | 'output', 102 | [...path, 'schema'], 103 | documentOpts, 104 | ); 105 | } 106 | return schema; 107 | }; 108 | 109 | export const createContent = ( 110 | content: unknown, 111 | components: ComponentsObject, 112 | path: string[], 113 | documentOpts?: CreateDocumentOptions, 114 | ): unknown => { 115 | if (typeof content !== 'object' || content == null) { 116 | return content; 117 | } 118 | 119 | return Object.entries(content).reduce( 120 | (acc, [key, value]: [string, unknown]) => { 121 | if (typeof value === 'object' && value !== null && 'schema' in value) { 122 | const schema = createResponseSchema( 123 | value.schema as FastifyResponseSchema, 124 | components, 125 | [...path, 'schema'], 126 | documentOpts, 127 | ); 128 | acc[key] = { 129 | ...value, 130 | schema, 131 | }; 132 | return acc; 133 | } 134 | acc[key] = value; 135 | return acc; 136 | }, 137 | {} as Record, 138 | ); 139 | }; 140 | 141 | export const createResponse = ( 142 | response: unknown, 143 | components: ComponentsObject, 144 | path: string[], 145 | documentOpts?: CreateDocumentOptions, 146 | ): unknown => { 147 | if (typeof response !== 'object' || response == null) { 148 | return response; 149 | } 150 | 151 | return Object.entries(response).reduce( 152 | (acc, [key, value]: [string, unknown]) => { 153 | if (isZodType(value)) { 154 | acc[key] = createMediaTypeSchema( 155 | value, 156 | components, 157 | 'output', 158 | [...path, key], 159 | documentOpts, 160 | ); 161 | return acc; 162 | } 163 | 164 | if (typeof value === 'object' && value !== null && 'content' in value) { 165 | const content = createContent( 166 | value.content, 167 | components, 168 | [...path, 'content'], 169 | documentOpts, 170 | ); 171 | acc[key] = { 172 | ...value, 173 | content, 174 | }; 175 | return acc; 176 | } 177 | 178 | acc[key] = value; 179 | return acc; 180 | }, 181 | {} as Record, 182 | ); 183 | }; 184 | 185 | export const fastifyZodOpenApiTransform: Transform = ({ 186 | schema, 187 | url, 188 | ...opts 189 | }) => { 190 | if (!schema || schema.hide) { 191 | return { 192 | schema, 193 | url, 194 | }; 195 | } 196 | 197 | const { response, headers, querystring, body, params } = schema; 198 | 199 | if (!('openapiObject' in opts)) { 200 | throw new Error('openapiObject was not found in the options'); 201 | } 202 | 203 | const config = schema[FASTIFY_ZOD_OPENAPI_CONFIG]; 204 | 205 | if (!config) { 206 | throw new Error('Please register the fastify-zod-openapi plugin'); 207 | } 208 | 209 | const { components, documentOpts } = config; 210 | 211 | // we need to access the components when we transform the document. Symbol's do not appear 212 | opts.openapiObject[FASTIFY_ZOD_OPENAPI_COMPONENTS] ??= config.components; 213 | 214 | if (opts.openapiObject.openapi) { 215 | components.openapi = opts.openapiObject.openapi as ZodOpenApiVersion; 216 | } 217 | 218 | opts.openapiObject[FASTIFY_ZOD_OPENAPI_COMPONENTS] ??= components; 219 | 220 | const transformedSchema: FastifySchema = { 221 | ...schema, 222 | }; 223 | 224 | if (isZodType(body)) { 225 | transformedSchema.body = createMediaTypeSchema( 226 | body, 227 | components, 228 | 'input', 229 | [url, 'body'], 230 | documentOpts, 231 | ); 232 | } 233 | 234 | const maybeResponse = createResponse( 235 | response, 236 | components, 237 | [url, 'response'], 238 | documentOpts, 239 | ); 240 | 241 | if (maybeResponse) { 242 | transformedSchema.response = maybeResponse; 243 | } 244 | 245 | if (isZodType(querystring)) { 246 | const queryStringSchema = getZodObject( 247 | querystring as ZodObjectInputType, 248 | 'input', 249 | ); 250 | transformedSchema.querystring = createParams( 251 | queryStringSchema, 252 | 'query', 253 | components, 254 | [url, 'querystring'], 255 | documentOpts, 256 | ); 257 | } 258 | 259 | if (isZodType(params)) { 260 | const paramsSchema = getZodObject(params as ZodObjectInputType, 'input'); 261 | transformedSchema.params = createParams(paramsSchema, 'path', components, [ 262 | url, 263 | 'params', 264 | ]); 265 | } 266 | 267 | if (isZodType(headers)) { 268 | const headersSchema = getZodObject(headers as ZodObjectInputType, 'input'); 269 | transformedSchema.headers = createParams( 270 | headersSchema, 271 | 'header', 272 | components, 273 | [url, 'headers'], 274 | ); 275 | } 276 | 277 | return { 278 | schema: transformedSchema, 279 | url, 280 | }; 281 | }; 282 | 283 | export const fastifyZodOpenApiTransformObject: TransformObject = (opts) => { 284 | if ('swaggerObject' in opts) { 285 | return opts.swaggerObject; 286 | } 287 | 288 | const components = opts.openapiObject[FASTIFY_ZOD_OPENAPI_COMPONENTS]; 289 | 290 | if (!components) { 291 | return opts.openapiObject; 292 | } 293 | 294 | return { 295 | ...opts.openapiObject, 296 | components: createComponents( 297 | (opts.openapiObject.components ?? {}) as ZodOpenApiComponentsObject, 298 | components, 299 | ) as OpenAPIV3.ComponentsObject, 300 | }; 301 | }; 302 | -------------------------------------------------------------------------------- /src/validationError.ts: -------------------------------------------------------------------------------- 1 | import { createError } from '@fastify/error'; 2 | import type { FastifySchemaValidationError } from 'fastify/types/schema'; 3 | import type { ZodError, ZodIssue, ZodIssueCode } from 'zod'; 4 | 5 | export class RequestValidationError 6 | extends Error 7 | implements FastifySchemaValidationError 8 | { 9 | cause!: ZodIssue; 10 | constructor( 11 | public keyword: ZodIssueCode, 12 | public instancePath: string, 13 | public schemaPath: string, 14 | public message: string, 15 | public params: { issue: ZodIssue; error: ZodError }, 16 | ) { 17 | super(message, { 18 | cause: params.issue, 19 | }); 20 | } 21 | } 22 | 23 | export class ResponseSerializationError extends createError( 24 | 'FST_ERR_RESPONSE_SERIALIZATION', 25 | 'Response does not match the schema', 26 | 500, 27 | ) { 28 | cause!: ZodError; 29 | constructor( 30 | public method: string, 31 | public url: string, 32 | options: { cause: ZodError }, 33 | ) { 34 | super(); 35 | this.cause = options.cause; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/validatorCompiler.test.ts: -------------------------------------------------------------------------------- 1 | import 'zod-openapi/extend'; 2 | import fastify from 'fastify'; 3 | import { z } from 'zod'; 4 | 5 | import type { FastifyZodOpenApiTypeProvider } from './plugin'; 6 | import { RequestValidationError } from './validationError'; 7 | import { validatorCompiler } from './validatorCompiler'; 8 | 9 | describe('validatorCompiler', () => { 10 | describe('querystring', () => { 11 | it('should pass a valid input', async () => { 12 | const app = fastify(); 13 | 14 | app.setValidatorCompiler(validatorCompiler); 15 | app.withTypeProvider().get( 16 | '/', 17 | { 18 | schema: { 19 | querystring: z.object({ 20 | jobId: z.string().openapi({ 21 | description: 'Job ID', 22 | example: '60002023', 23 | }), 24 | }), 25 | }, 26 | }, 27 | (req, res) => res.send(req.query), 28 | ); 29 | await app.ready(); 30 | 31 | const result = await app.inject().get('/').query({ jobId: '60002023' }); 32 | 33 | expect(result.json()).toEqual({ jobId: '60002023' }); 34 | }); 35 | 36 | it('should fail an invalid input', async () => { 37 | const app = fastify(); 38 | 39 | app.setValidatorCompiler(validatorCompiler); 40 | app.withTypeProvider().get( 41 | '/', 42 | { 43 | schema: { 44 | querystring: z.object({ 45 | jobId: z.coerce.number().openapi({ 46 | description: 'Job ID', 47 | example: 60002023, 48 | }), 49 | }), 50 | }, 51 | }, 52 | (req, res) => res.send(req.query), 53 | ); 54 | await app.ready(); 55 | 56 | const result = await app.inject().get('/').query({ jobId: 'a' }); 57 | 58 | expect(result.statusCode).toBe(400); 59 | expect(result.json()).toMatchInlineSnapshot(` 60 | { 61 | "code": "FST_ERR_VALIDATION", 62 | "error": "Bad Request", 63 | "message": "querystring/jobId Expected number, received nan", 64 | "statusCode": 400, 65 | } 66 | `); 67 | }); 68 | }); 69 | 70 | describe('body', () => { 71 | it('should pass a valid input', async () => { 72 | const app = fastify(); 73 | 74 | app.setValidatorCompiler(validatorCompiler); 75 | app.withTypeProvider().post( 76 | '/', 77 | { 78 | schema: { 79 | body: z.object({ 80 | jobId: z.string().openapi({ 81 | description: 'Job ID', 82 | example: '60002023', 83 | }), 84 | }), 85 | }, 86 | }, 87 | (req, res) => res.send(req.body), 88 | ); 89 | await app.ready(); 90 | 91 | const result = await app.inject().post('/').body({ jobId: '60002023' }); 92 | 93 | expect(result.json()).toEqual({ jobId: '60002023' }); 94 | }); 95 | 96 | it('should fail an invalid input', async () => { 97 | const app = fastify(); 98 | 99 | app.setValidatorCompiler(validatorCompiler); 100 | app.withTypeProvider().post( 101 | '/', 102 | { 103 | schema: { 104 | body: z.object({ 105 | jobId: z.coerce.number().openapi({ 106 | description: 'Job ID', 107 | example: 60002023, 108 | }), 109 | }), 110 | }, 111 | }, 112 | (req, res) => res.send(req.body), 113 | ); 114 | await app.ready(); 115 | 116 | const result = await app.inject().post('/').body({ jobId: 'a' }); 117 | 118 | expect(result.statusCode).toBe(400); 119 | expect(result.json()).toMatchInlineSnapshot(` 120 | { 121 | "code": "FST_ERR_VALIDATION", 122 | "error": "Bad Request", 123 | "message": "body/jobId Expected number, received nan", 124 | "statusCode": 400, 125 | } 126 | `); 127 | }); 128 | }); 129 | 130 | describe('headers', () => { 131 | it('should pass a valid input', async () => { 132 | const app = fastify(); 133 | 134 | app.setValidatorCompiler(validatorCompiler); 135 | app.withTypeProvider().get( 136 | '/', 137 | { 138 | schema: { 139 | headers: z.object({ 140 | 'job-id': z.string().openapi({ 141 | description: 'Job ID', 142 | example: '60002023', 143 | }), 144 | }), 145 | }, 146 | }, 147 | (req, res) => res.send(req.headers), 148 | ); 149 | await app.ready(); 150 | 151 | const result = await app 152 | .inject() 153 | .get('/') 154 | .headers({ 'job-id': '60002023' }); 155 | 156 | expect(result.json()).toMatchObject({ 'job-id': '60002023' }); 157 | }); 158 | 159 | it('should fail an invalid input', async () => { 160 | const app = fastify(); 161 | 162 | app.setValidatorCompiler(validatorCompiler); 163 | app.withTypeProvider().get( 164 | '/', 165 | { 166 | schema: { 167 | headers: z.object({ 168 | jobId: z.coerce.number().openapi({ 169 | description: 'Job ID', 170 | example: 60002023, 171 | }), 172 | }), 173 | }, 174 | }, 175 | (req, res) => res.send(req.headers), 176 | ); 177 | await app.ready(); 178 | 179 | const result = await app.inject().get('/').headers({ jobId: 'a' }); 180 | 181 | expect(result.statusCode).toBe(400); 182 | expect(result.json()).toMatchInlineSnapshot(` 183 | { 184 | "code": "FST_ERR_VALIDATION", 185 | "error": "Bad Request", 186 | "message": "headers/jobId Expected number, received nan", 187 | "statusCode": 400, 188 | } 189 | `); 190 | }); 191 | }); 192 | 193 | describe('params', () => { 194 | it('should pass a valid input', async () => { 195 | const app = fastify(); 196 | 197 | app.setValidatorCompiler(validatorCompiler); 198 | app.withTypeProvider().get( 199 | '/:jobId', 200 | { 201 | schema: { 202 | params: z.object({ 203 | jobId: z.string().openapi({ 204 | description: 'Job ID', 205 | example: '60002023', 206 | }), 207 | }), 208 | }, 209 | }, 210 | (req, res) => res.send(req.params), 211 | ); 212 | await app.ready(); 213 | 214 | const result = await app.inject().get('/60002023'); 215 | 216 | expect(result.json()).toEqual({ jobId: '60002023' }); 217 | }); 218 | 219 | it('should fail an invalid input', async () => { 220 | const app = fastify(); 221 | 222 | app.setValidatorCompiler(validatorCompiler); 223 | app.withTypeProvider().get( 224 | '/:jobId', 225 | { 226 | schema: { 227 | params: z.object({ 228 | jobId: z.coerce.number().openapi({ 229 | description: 'Job ID', 230 | example: 60002023, 231 | }), 232 | }), 233 | }, 234 | }, 235 | (req, res) => res.send(req.headers), 236 | ); 237 | await app.ready(); 238 | 239 | const result = await app.inject().get('/a'); 240 | 241 | expect(result.statusCode).toBe(400); 242 | expect(result.json()).toMatchInlineSnapshot(` 243 | { 244 | "code": "FST_ERR_VALIDATION", 245 | "error": "Bad Request", 246 | "message": "params/jobId Expected number, received nan", 247 | "statusCode": 400, 248 | } 249 | `); 250 | }); 251 | }); 252 | }); 253 | 254 | describe('attachValidation', () => { 255 | it('should support handling validationError in requests', async () => { 256 | const app = fastify(); 257 | 258 | app.setValidatorCompiler(validatorCompiler); 259 | app.withTypeProvider().get( 260 | '/', 261 | { 262 | schema: { 263 | querystring: z.object({ 264 | jobId: z.string().openapi({ 265 | description: 'Job ID', 266 | example: '60002023', 267 | }), 268 | }), 269 | }, 270 | attachValidation: true, 271 | }, 272 | (req, res) => { 273 | if (req.validationError) { 274 | for (const error of req.validationError.validation) { 275 | if (error instanceof RequestValidationError) { 276 | return res.status(400).send({ 277 | custom: 'message', 278 | instancePath: error.instancePath, 279 | validationContext: req.validationError.validationContext, 280 | }); 281 | } 282 | } 283 | } 284 | 285 | return res.send(req.query); 286 | }, 287 | ); 288 | 289 | await app.ready(); 290 | 291 | const result = await app.inject().get('/').query({ foo: 'foo' }); 292 | 293 | expect(result.json()).toEqual({ 294 | custom: 'message', 295 | instancePath: '/jobId', 296 | validationContext: 'querystring', 297 | }); 298 | }); 299 | }); 300 | 301 | describe('setSchemaErrorFormatter', () => { 302 | it('should support setting a setSchemaErrorFormatter', async () => { 303 | const app = fastify(); 304 | 305 | app.setValidatorCompiler(validatorCompiler); 306 | app.withTypeProvider().get( 307 | '/', 308 | { 309 | schema: { 310 | querystring: z.object({ 311 | jobId: z.string().openapi({ 312 | description: 'Job ID', 313 | example: '60002023', 314 | }), 315 | }), 316 | }, 317 | }, 318 | (req, res) => res.send(req.query), 319 | ); 320 | 321 | app.setSchemaErrorFormatter((errors, dataVar) => { 322 | let message = dataVar; 323 | for (const error of errors) { 324 | if (error instanceof RequestValidationError) { 325 | message += ` ${error.instancePath} ${error.keyword}`; 326 | } 327 | } 328 | 329 | return new Error(message); 330 | }); 331 | 332 | await app.ready(); 333 | 334 | const result = await app.inject().get('/').query({ foo: 'foo' }); 335 | 336 | expect(result.json()).toEqual({ 337 | code: 'FST_ERR_VALIDATION', 338 | error: 'Bad Request', 339 | message: 'querystring /jobId invalid_type', 340 | statusCode: 400, 341 | }); 342 | }); 343 | }); 344 | 345 | describe('setErrorHandler', () => { 346 | it('should support setting a custom error handler', async () => { 347 | const app = fastify(); 348 | 349 | app.setValidatorCompiler(validatorCompiler); 350 | app.withTypeProvider().get( 351 | '/', 352 | { 353 | schema: { 354 | querystring: z.object({ 355 | jobId: z.string().openapi({ 356 | description: 'Job ID', 357 | example: '60002023', 358 | }), 359 | }), 360 | }, 361 | }, 362 | (req, res) => res.send(req.query), 363 | ); 364 | app.setErrorHandler((error, _req, res) => { 365 | if (error.validation) { 366 | for (const err of error.validation) { 367 | if (err instanceof RequestValidationError) { 368 | return res.status(400).send({ 369 | custom: 'message', 370 | instancePath: err.instancePath, 371 | validationContext: error.validationContext, 372 | }); 373 | } 374 | } 375 | } 376 | return res.status(500).send({ 377 | message: 'Unhandled error', 378 | }); 379 | }); 380 | 381 | const result = await app.inject().get('/').query({ foo: 'foo' }); 382 | 383 | expect(result.json()).toEqual({ 384 | custom: 'message', 385 | instancePath: '/jobId', 386 | validationContext: 'querystring', 387 | }); 388 | }); 389 | 390 | it('should surface the original zod error and zod issue', async () => { 391 | const app = fastify(); 392 | 393 | app.setValidatorCompiler(validatorCompiler); 394 | app.withTypeProvider().get( 395 | '/', 396 | { 397 | schema: { 398 | querystring: z.object({ 399 | jobId: z.string().openapi({ 400 | description: 'Job ID', 401 | example: '60002023', 402 | }), 403 | }), 404 | }, 405 | }, 406 | (req, res) => res.send(req.query), 407 | ); 408 | app.setErrorHandler((error, _req, res) => { 409 | if (error.validation) { 410 | for (const err of error.validation) { 411 | if (err instanceof RequestValidationError) { 412 | return res.status(400).send({ 413 | zodIssue: err.params.issue, 414 | zodError: err.params.error, 415 | }); 416 | } 417 | } 418 | } 419 | return res.status(500).send({ 420 | message: 'Unhandled error', 421 | }); 422 | }); 423 | 424 | const result = await app.inject().get('/').query({ foo: 'foo' }); 425 | 426 | expect(result.json()).toMatchInlineSnapshot(` 427 | { 428 | "zodError": { 429 | "issues": [ 430 | { 431 | "code": "invalid_type", 432 | "expected": "string", 433 | "message": "Required", 434 | "path": [ 435 | "jobId", 436 | ], 437 | "received": "undefined", 438 | }, 439 | ], 440 | "name": "ZodError", 441 | }, 442 | "zodIssue": { 443 | "code": "invalid_type", 444 | "expected": "string", 445 | "message": "Required", 446 | "path": [ 447 | "jobId", 448 | ], 449 | "received": "undefined", 450 | }, 451 | } 452 | `); 453 | }); 454 | 455 | it('should map Zod Issues as RequestValidationError errors', async () => { 456 | const app = fastify(); 457 | 458 | app.setValidatorCompiler(validatorCompiler); 459 | app.withTypeProvider().get( 460 | '/', 461 | { 462 | schema: { 463 | querystring: z.object({ 464 | jobId: z.string().openapi({ 465 | description: 'Job ID', 466 | example: '60002023', 467 | }), 468 | jobTitle: z.string(), 469 | }), 470 | }, 471 | }, 472 | (req, res) => res.send(req.query), 473 | ); 474 | app.setErrorHandler((error, _req, res) => { 475 | if (error.validation) { 476 | const errs = error.validation.map((err) => { 477 | if (err instanceof RequestValidationError) { 478 | return { 479 | zodIssue: err.params.issue, 480 | }; 481 | } 482 | return err; 483 | }); 484 | return res.status(400).send({ 485 | errors: errs, 486 | }); 487 | } 488 | return res.status(500).send({ 489 | message: 'Unhandled error', 490 | }); 491 | }); 492 | 493 | const result = await app.inject().get('/').query({ foo: 'foo' }); 494 | 495 | expect(result.json()).toMatchInlineSnapshot(` 496 | { 497 | "errors": [ 498 | { 499 | "zodIssue": { 500 | "code": "invalid_type", 501 | "expected": "string", 502 | "message": "Required", 503 | "path": [ 504 | "jobId", 505 | ], 506 | "received": "undefined", 507 | }, 508 | }, 509 | { 510 | "zodIssue": { 511 | "code": "invalid_type", 512 | "expected": "string", 513 | "message": "Required", 514 | "path": [ 515 | "jobTitle", 516 | ], 517 | "received": "undefined", 518 | }, 519 | }, 520 | ], 521 | } 522 | `); 523 | }); 524 | }); 525 | -------------------------------------------------------------------------------- /src/validatorCompiler.ts: -------------------------------------------------------------------------------- 1 | import type { FastifySchemaCompiler } from 'fastify'; 2 | import type { ZodType } from 'zod'; 3 | 4 | import { RequestValidationError } from './validationError'; 5 | 6 | /** 7 | * Enables zod-openapi schema validation 8 | * 9 | * @example 10 | * ```typescript 11 | * import Fastify from 'fastify' 12 | * 13 | * const server = Fastify().setValidatorCompiler(validatorCompiler) 14 | * ``` 15 | */ 16 | export const validatorCompiler: FastifySchemaCompiler = 17 | ({ schema }) => 18 | (value) => { 19 | const result = schema.safeParse(value); 20 | 21 | if (!result.success) { 22 | return { 23 | error: result.error.errors.map( 24 | (issue) => 25 | new RequestValidationError( 26 | issue.code, 27 | `/${issue.path.join('/')}`, 28 | `#/${issue.path.join('/')}/${issue.code}`, 29 | issue.message, 30 | { 31 | issue, 32 | error: result.error, 33 | }, 34 | ), 35 | ) as unknown as Error, // Types are wrong https://github.com/fastify/fastify/pull/5787 36 | }; 37 | } 38 | 39 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 40 | return { value: result.data }; 41 | }; 42 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["**/__mocks__/**/*", "**/*.test.ts", "src/testing/**/*"], 3 | "extends": "./tsconfig.json", 4 | "include": ["src/**/*"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022"], 4 | "outDir": "lib", 5 | "removeComments": false, 6 | "target": "ES2022" 7 | }, 8 | "exclude": ["lib*/**/*", "dist*/**/*"], 9 | "extends": "skuba/config/tsconfig.json" 10 | } 11 | --------------------------------------------------------------------------------