├── .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 |
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 |
--------------------------------------------------------------------------------