├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── example.mjs ├── index.js ├── package.json ├── test ├── ajv.test.js └── index.test.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fastify 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 | # @fastify/response-validation 2 | 3 | [![CI](https://github.com/fastify/fastify-response-validation/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-response-validation/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/response-validation.svg?style=flat)](https://www.npmjs.com/package/@fastify/response-validation) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | A simple plugin that enables response validation for Fastify. 8 | The use of this plugin will slow down your overall performance, so we suggest using it only during development. 9 | 10 | ## Install 11 | ``` 12 | npm i @fastify/response-validation 13 | ``` 14 | 15 | ## Usage 16 | You just need to register the plugin and you will have response validation enabled: 17 | ```js 18 | import fastify from 'fastify' 19 | 20 | const app = fastify() 21 | 22 | await app.register(require('@fastify/response-validation')) 23 | 24 | app.route({ 25 | method: 'GET', 26 | path: '/', 27 | schema: { 28 | response: { 29 | '2xx': { 30 | type: 'object', 31 | properties: { 32 | answer: { type: 'number' } 33 | } 34 | } 35 | } 36 | }, 37 | handler: async (req, reply) => { 38 | return { answer: '42' } 39 | } 40 | }) 41 | 42 | app.inject({ 43 | method: 'GET', 44 | path: '/' 45 | }, (err, res) => { 46 | if (err) throw err 47 | console.log(res.payload) 48 | }) 49 | ``` 50 | 51 | Different content types responses are supported by `@fastify/response-validation`, `@fastify/swagger`, and `fastify`. Please use `content` for the response otherwise Fastify itself will fail to compile the schema: 52 | ```js 53 | { 54 | response: { 55 | 200: { 56 | description: 'Description and all status-code based properties are working', 57 | content: { 58 | 'application/json': { 59 | schema: { 60 | name: { type: 'string' }, 61 | image: { type: 'string' }, 62 | address: { type: 'string' } 63 | } 64 | }, 65 | 'application/vnd.v1+json': { 66 | schema: { 67 | fullName: { type: 'string' }, 68 | phone: { type: 'string' } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | If you want to override the default [ajv](https://www.npmjs.com/package/ajv) configuration, you can do that by using the `ajv` option: 78 | ```js 79 | // Default configuration: 80 | // coerceTypes: false 81 | // useDefaults: true 82 | // removeAdditional: true 83 | // allErrors: true 84 | 85 | import responseValidator from '@fastify/response-validation' 86 | 87 | // ... App setup 88 | 89 | await fastify.register(responseValidator, { 90 | ajv: { 91 | coerceTypes: true 92 | } 93 | }) 94 | ``` 95 | 96 | You can also pass in an instance of ajv 97 | ```js 98 | // Default configuration: 99 | // coerceTypes: false 100 | // useDefaults: true 101 | // removeAdditional: true 102 | // allErrors: true 103 | 104 | import responseValidator from '@fastify/response-validation' 105 | import Ajv from 'ajv' 106 | 107 | // ... App setup 108 | 109 | const ajv = new Ajv() 110 | await fastify.register(responseValidator, { ajv }) 111 | ``` 112 | 113 | By default, the response validation is enabled on every route that has a response schema defined. If needed you can disable it all together with `responseValidation: false`: 114 | ```js 115 | import responseValidator from '@fastify/response-validation' 116 | 117 | // ... App setup 118 | await fastify.register(responseValidator, { 119 | responseValidation: false 120 | }) 121 | ``` 122 | 123 | Alternatively, you can disable a specific route with the same option: 124 | ```js 125 | fastify.route({ 126 | method: 'GET', 127 | path: '/', 128 | responseValidation: false, 129 | schema: { 130 | response: { 131 | '2xx': { 132 | type: 'object', 133 | properties: { 134 | answer: { type: 'number' } 135 | } 136 | } 137 | } 138 | }, 139 | handler: async (req, reply) => { 140 | return { answer: '42' } 141 | } 142 | }) 143 | ``` 144 | 145 | ## Plugins 146 | You can also extend the functionalities of the ajv instance embedded in this validator by adding new ajv plugins. 147 | 148 | ```js 149 | fastify.register(require('fastify-response-validation'), { 150 | ajv: { 151 | plugins: [ 152 | require('ajv-formats'), 153 | [require('ajv-errors'), { singleError: false }] 154 | // Usage: [plugin, pluginOptions] - Plugin with options 155 | // Usage: plugin - Plugin without options 156 | ] 157 | } 158 | }) 159 | ``` 160 | 161 | ## Errors 162 | 163 | The errors emitted by this plugin are: 164 | 165 | - `FST_RESPONSE_VALIDATION_FAILED_VALIDATION`: This error is emitted when a response does not conform to the provided schema. 166 | 167 | - `FST_RESPONSE_VALIDATION_SCHEMA_NOT_DEFINED`: This error is emitted when there is no JSON schema available to validate the response. 168 | 169 | ## License 170 | [MIT](./LICENSE) 171 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /example.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import fastify from 'fastify' 4 | import responseValidator from './index.js' 5 | 6 | const app = fastify() 7 | 8 | await app.register(responseValidator) 9 | 10 | app.get('/', { 11 | schema: { 12 | response: { 13 | '2xx': { 14 | type: 'object', 15 | properties: { 16 | answer: { type: 'number' } 17 | } 18 | } 19 | } 20 | }, 21 | handler: () => { 22 | return { answer: '42' } 23 | } 24 | }) 25 | 26 | app.inject( 27 | { 28 | method: 'GET', 29 | path: '/' 30 | }, 31 | (err, res) => { 32 | if (err) throw err 33 | console.log(res.payload) 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const Ajv = require('ajv') 5 | const AjvCore = require('ajv/dist/core') 6 | const createError = require('@fastify/error') 7 | 8 | const ReplyValidationFailError = createError('FST_RESPONSE_VALIDATION_FAILED_VALIDATION', '%s', 500) 9 | const NoSchemaDefinedError = createError('FST_RESPONSE_VALIDATION_SCHEMA_NOT_DEFINED', 'No schema defined for %s') 10 | 11 | function fastifyResponseValidation (fastify, opts, next) { 12 | let ajv 13 | if (opts.ajv && opts.ajv instanceof AjvCore.default) { 14 | ajv = opts.ajv 15 | } else { 16 | const { plugins: ajvPlugins, ...ajvOptions } = Object.assign({ 17 | coerceTypes: false, 18 | useDefaults: true, 19 | removeAdditional: true, 20 | allErrors: true, 21 | plugins: [] 22 | }, opts.ajv) 23 | 24 | if (!Array.isArray(ajvPlugins)) { 25 | next(new Error(`ajv.plugins option should be an array, instead got '${typeof ajvPlugins}'`)) 26 | return 27 | } 28 | ajv = new Ajv(ajvOptions) 29 | 30 | for (const plugin of ajvPlugins) { 31 | if (Array.isArray(plugin)) { 32 | plugin[0](ajv, plugin[1]) 33 | } else { 34 | plugin(ajv) 35 | } 36 | } 37 | } 38 | 39 | if (opts.responseValidation !== false) { 40 | fastify.addHook('onRoute', onRoute) 41 | } 42 | 43 | function onRoute (routeOpts) { 44 | if (routeOpts.responseValidation === false) return 45 | if (routeOpts.schema?.response) { 46 | const responseStatusCodeValidation = routeOpts.responseStatusCodeValidation || opts.responseStatusCodeValidation || false 47 | routeOpts.preSerialization = routeOpts.preSerialization || [] 48 | routeOpts.preSerialization.push(buildHook(routeOpts.schema.response, responseStatusCodeValidation)) 49 | } 50 | } 51 | 52 | function buildHook (schema, responseStatusCodeValidation) { 53 | const statusCodes = {} 54 | for (const originalCode in schema) { 55 | /** 56 | * Internally we are going to treat status codes as lower-case strings to preserve compatibility 57 | * with previous versions of this plugin allowing 4xx/5xx status codes to be defined as strings 58 | * even though the OpenAPI spec requires them to be upper-case or the word 'default'. 59 | * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responses-object} 60 | * @see {@link https://swagger.io/specification/#fixed-fields-14} 61 | */ 62 | const statusCode = originalCode.toLowerCase() 63 | const responseSchema = schema[originalCode] 64 | 65 | if (responseSchema.content !== undefined) { 66 | statusCodes[statusCode] = {} 67 | for (const mediaName in responseSchema.content) { 68 | statusCodes[statusCode][mediaName] = ajv.compile( 69 | getSchemaAnyway(responseSchema.content[mediaName].schema) 70 | ) 71 | } 72 | } else { 73 | statusCodes[statusCode] = ajv.compile( 74 | getSchemaAnyway(responseSchema) 75 | ) 76 | } 77 | } 78 | 79 | return preSerialization 80 | 81 | function preSerialization (_req, reply, payload, next) { 82 | let validate = statusCodes[reply.statusCode] || statusCodes[(reply.statusCode + '')[0] + 'xx'] || statusCodes.default 83 | 84 | if (responseStatusCodeValidation && validate === undefined) { 85 | next(new NoSchemaDefinedError(`status code ${reply.statusCode}`)) 86 | return 87 | } 88 | 89 | if (validate !== undefined) { 90 | // Per media type validation 91 | if (validate.constructor === Object) { 92 | const mediaName = reply.getHeader('content-type').split(';', 1)[0] 93 | if (validate[mediaName] == null) { 94 | next(new NoSchemaDefinedError(`media type ${mediaName}`)) 95 | return 96 | } 97 | validate = validate[mediaName] 98 | } 99 | 100 | const valid = validate(payload) 101 | if (!valid) { 102 | const err = new ReplyValidationFailError(schemaErrorsText(validate.errors)) 103 | err.validation = validate.errors 104 | reply.code(err.statusCode) 105 | return next(err) 106 | } 107 | } 108 | 109 | next() 110 | } 111 | } 112 | 113 | next() 114 | } 115 | 116 | /** 117 | * Copy-paste of getSchemaAnyway from fastify 118 | * 119 | * https://github.com/fastify/fastify/blob/23371945d01c270af24f4a5b7e2e31c4e806e6b3/lib/schemas.js#L113 120 | */ 121 | function getSchemaAnyway (schema) { 122 | if (schema.$ref || schema.oneOf || schema.allOf || schema.anyOf || schema.$merge || schema.$patch) return schema 123 | if (!schema.type && !schema.properties) { 124 | return { 125 | type: 'object', 126 | properties: schema 127 | } 128 | } 129 | return schema 130 | } 131 | 132 | function schemaErrorsText (errors) { 133 | let text = '' 134 | const separator = ', ' 135 | for (const e of errors) { 136 | text += 'response' + (e.instancePath || '') + ' ' + e.message + separator 137 | } 138 | return text.slice(0, -separator.length) 139 | } 140 | 141 | module.exports = fp(fastifyResponseValidation, { 142 | fastify: '5.x', 143 | name: '@fastify/response-validation' 144 | }) 145 | module.exports.default = fastifyResponseValidation 146 | module.exports.fastifyResponseValidation = fastifyResponseValidation 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/response-validation", 3 | "version": "3.0.3", 4 | "description": "A simple plugin that enables response validation for Fastify.", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "dependencies": { 8 | "@fastify/error": "^4.0.0", 9 | "ajv": "^8.12.0", 10 | "fastify-plugin": "^5.0.1" 11 | }, 12 | "types": "types/index.d.ts", 13 | "devDependencies": { 14 | "@fastify/pre-commit": "^2.1.0", 15 | "@types/node": "^22.0.0", 16 | "ajv-errors": "^3.0.0", 17 | "ajv-formats": "^3.0.1", 18 | "c8": "^10.1.2", 19 | "eslint": "^9.17.0", 20 | "fastify": "^5.0.0", 21 | "neostandard": "^0.12.0", 22 | "tsd": "^0.32.0" 23 | }, 24 | "scripts": { 25 | "lint": "eslint", 26 | "lint:fix": "eslint --fix", 27 | "test": "npm run test:unit && npm run test:typescript", 28 | "test:unit": "c8 --100 node --test", 29 | "test:typescript": "tsd" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/fastify/fastify-response-validation.git" 34 | }, 35 | "keywords": [ 36 | "fastify", 37 | "validation", 38 | "response", 39 | "json", 40 | "schema" 41 | ], 42 | "author": "Tomas Della Vedova", 43 | "contributors": [ 44 | { 45 | "name": "Matteo Collina", 46 | "email": "hello@matteocollina.com" 47 | }, 48 | { 49 | "name": "Manuel Spigolon", 50 | "email": "behemoth89@gmail.com" 51 | }, 52 | { 53 | "name": "James Sumners", 54 | "url": "https://james.sumners.info" 55 | }, 56 | { 57 | "name": "Frazer Smith", 58 | "email": "frazer.dev@icloud.com", 59 | "url": "https://github.com/fdawgs" 60 | } 61 | ], 62 | "license": "MIT", 63 | "bugs": { 64 | "url": "https://github.com/fastify/fastify-response-validation/issues" 65 | }, 66 | "homepage": "https://github.com/fastify/fastify-response-validation#readme", 67 | "funding": [ 68 | { 69 | "type": "github", 70 | "url": "https://github.com/sponsors/fastify" 71 | }, 72 | { 73 | "type": "opencollective", 74 | "url": "https://opencollective.com/fastify" 75 | } 76 | ], 77 | "publishConfig": { 78 | "access": "public" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/ajv.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const plugin = require('..') 6 | const Ajv = require('ajv') 7 | const Ajv2019 = require('ajv/dist/2019') 8 | const Ajv2020 = require('ajv/dist/2020') 9 | const ajvFormats = require('ajv-formats') 10 | const ajvErrors = require('ajv-errors') 11 | 12 | test('use ajv formats', async t => { 13 | const fastify = Fastify() 14 | await fastify.register(plugin, { ajv: { plugins: [require('ajv-formats')] } }) 15 | 16 | fastify.route({ 17 | method: 'GET', 18 | url: '/', 19 | schema: { 20 | response: { 21 | '2xx': { 22 | type: 'object', 23 | properties: { 24 | answer: { type: 'number', format: 'float' } 25 | } 26 | } 27 | } 28 | }, 29 | handler: async () => { 30 | return { answer: 2.4 } 31 | } 32 | }) 33 | 34 | const response = await fastify.inject({ 35 | method: 'GET', 36 | url: '/' 37 | }) 38 | 39 | t.assert.strictEqual(response.statusCode, 200) 40 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 2.4 }) 41 | }) 42 | 43 | test('use ajv errors', async t => { 44 | const fastify = Fastify() 45 | await fastify.register(plugin, { ajv: { plugins: [[require('ajv-errors'), { singleError: false }]] } }) 46 | 47 | fastify.route({ 48 | method: 'GET', 49 | url: '/', 50 | schema: { 51 | response: { 52 | '2xx': { 53 | type: 'object', 54 | required: ['answer'], 55 | properties: { 56 | answer: { type: 'number' } 57 | }, 58 | additionalProperties: false, 59 | errorMessage: 'should be an object with an integer property answer only' 60 | } 61 | } 62 | }, 63 | handler: async () => { 64 | return { foo: 24 } 65 | } 66 | }) 67 | 68 | const response = await fastify.inject({ 69 | method: 'GET', 70 | url: '/' 71 | }) 72 | 73 | t.assert.strictEqual(response.statusCode, 500) 74 | t.assert.strictEqual(JSON.parse(response.payload).message, 'response should be an object with an integer property answer only') 75 | }) 76 | 77 | test('should throw an error if ajv.plugins is string', async t => { 78 | t.plan(2) 79 | const fastify = Fastify() 80 | await t.assert.rejects( 81 | async () => fastify.register(plugin, { ajv: { plugins: 'invalid' } }), 82 | (err) => { 83 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'string\'') 84 | return true 85 | }) 86 | }) 87 | 88 | test('should throw an error if ajv.plugins is null', async t => { 89 | t.plan(2) 90 | const fastify = Fastify() 91 | await t.assert.rejects( 92 | async () => fastify.register(plugin, { ajv: { plugins: null } }), 93 | (err) => { 94 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'object\'') 95 | return true 96 | }) 97 | }) 98 | 99 | test('should throw an error if ajv.plugins is undefined', async t => { 100 | t.plan(2) 101 | const fastify = Fastify() 102 | await t.assert.rejects( 103 | async () => fastify.register(plugin, { ajv: { plugins: undefined } }), 104 | (err) => { 105 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'undefined\'') 106 | return true 107 | }) 108 | }) 109 | 110 | test('should throw an error if ajv.plugins is boolean', async t => { 111 | t.plan(2) 112 | const fastify = Fastify() 113 | await t.assert.rejects( 114 | async () => fastify.register(plugin, { ajv: { plugins: false } }), 115 | (err) => { 116 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'boolean\'') 117 | return true 118 | }) 119 | }) 120 | 121 | test('should throw an error if ajv.plugins is number', async t => { 122 | t.plan(2) 123 | const fastify = Fastify() 124 | await t.assert.rejects( 125 | async () => fastify.register(plugin, { ajv: { plugins: 0 } }), 126 | (err) => { 127 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'number\'') 128 | return true 129 | }) 130 | }) 131 | 132 | test('use ajv formats with Ajv instance', async t => { 133 | const fastify = Fastify() 134 | const ajv = new Ajv() 135 | ajvFormats(ajv) 136 | await fastify.register(plugin, { ajv }) 137 | 138 | fastify.route({ 139 | method: 'GET', 140 | url: '/', 141 | schema: { 142 | response: { 143 | '2xx': { 144 | type: 'object', 145 | properties: { 146 | answer: { type: 'number', format: 'float' } 147 | } 148 | } 149 | } 150 | }, 151 | handler: async () => { 152 | return { answer: 2.4 } 153 | } 154 | }) 155 | 156 | const response = await fastify.inject({ 157 | method: 'GET', 158 | url: '/' 159 | }) 160 | 161 | t.assert.strictEqual(response.statusCode, 200) 162 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 2.4 }) 163 | }) 164 | 165 | test('use ajv errors with Ajv instance', async t => { 166 | const fastify = Fastify() 167 | const ajv = new Ajv({ allErrors: true }) 168 | ajvErrors(ajv, { singleError: true }) 169 | await fastify.register(plugin, { ajv }) 170 | 171 | fastify.route({ 172 | method: 'GET', 173 | url: '/', 174 | schema: { 175 | response: { 176 | '2xx': { 177 | type: 'object', 178 | required: ['answer'], 179 | properties: { 180 | answer: { type: 'number' } 181 | }, 182 | additionalProperties: false, 183 | errorMessage: 'should be an object with an integer property answer only' 184 | } 185 | } 186 | }, 187 | handler: async () => { 188 | return { notAnAnswer: 24 } 189 | } 190 | }) 191 | 192 | const response = await fastify.inject({ 193 | method: 'GET', 194 | url: '/' 195 | }) 196 | 197 | t.assert.strictEqual(response.statusCode, 500) 198 | t.assert.strictEqual(response.json().message, 'response should be an object with an integer property answer only') 199 | }) 200 | 201 | test('use ajv formats with 2019 Ajv instance', async t => { 202 | const fastify = Fastify() 203 | const ajv = new Ajv2019() 204 | ajvFormats(ajv) 205 | await fastify.register(plugin, { ajv }) 206 | 207 | fastify.route({ 208 | method: 'GET', 209 | url: '/', 210 | schema: { 211 | response: { 212 | '2xx': { 213 | type: 'object', 214 | properties: { 215 | answer: { type: 'number', format: 'float' } 216 | } 217 | } 218 | } 219 | }, 220 | handler: async () => { 221 | return { answer: 2.4 } 222 | } 223 | }) 224 | 225 | const response = await fastify.inject({ 226 | method: 'GET', 227 | url: '/' 228 | }) 229 | 230 | t.assert.strictEqual(response.statusCode, 200) 231 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 2.4 }) 232 | }) 233 | 234 | test('use ajv errors with 2019 Ajv instance', async t => { 235 | const fastify = Fastify() 236 | const ajv = new Ajv2019({ allErrors: true }) 237 | ajvErrors(ajv, { singleError: true }) 238 | await fastify.register(plugin, { ajv }) 239 | 240 | fastify.route({ 241 | method: 'GET', 242 | url: '/', 243 | schema: { 244 | response: { 245 | '2xx': { 246 | type: 'object', 247 | required: ['answer'], 248 | properties: { 249 | answer: { type: 'number' } 250 | }, 251 | additionalProperties: false, 252 | errorMessage: 'should be an object with an integer property answer only' 253 | } 254 | } 255 | }, 256 | handler: async () => { 257 | return { notAnAnswer: 24 } 258 | } 259 | }) 260 | 261 | const response = await fastify.inject({ 262 | method: 'GET', 263 | url: '/' 264 | }) 265 | 266 | t.assert.strictEqual(response.statusCode, 500) 267 | t.assert.strictEqual(response.json().message, 'response should be an object with an integer property answer only') 268 | }) 269 | 270 | test('use ajv formats with 2020 Ajv instance', async t => { 271 | const fastify = Fastify() 272 | const ajv = new Ajv2020() 273 | ajvFormats(ajv) 274 | await fastify.register(plugin, { ajv }) 275 | 276 | fastify.route({ 277 | method: 'GET', 278 | url: '/', 279 | schema: { 280 | response: { 281 | '2xx': { 282 | type: 'object', 283 | properties: { 284 | answer: { type: 'number', format: 'float' } 285 | } 286 | } 287 | } 288 | }, 289 | handler: async () => { 290 | return { answer: 2.4 } 291 | } 292 | }) 293 | 294 | const response = await fastify.inject({ 295 | method: 'GET', 296 | url: '/' 297 | }) 298 | 299 | t.assert.strictEqual(response.statusCode, 200) 300 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 2.4 }) 301 | }) 302 | 303 | test('use ajv errors with 2019 Ajv instance', async t => { 304 | const fastify = Fastify() 305 | const ajv = new Ajv2020({ allErrors: true }) 306 | ajvErrors(ajv, { singleError: true }) 307 | await fastify.register(plugin, { ajv }) 308 | 309 | fastify.route({ 310 | method: 'GET', 311 | url: '/', 312 | schema: { 313 | response: { 314 | '2xx': { 315 | type: 'object', 316 | required: ['answer'], 317 | properties: { 318 | answer: { type: 'number' } 319 | }, 320 | additionalProperties: false, 321 | errorMessage: 'should be an object with an integer property answer only' 322 | } 323 | } 324 | }, 325 | handler: async () => { 326 | return { notAnAnswer: 24 } 327 | } 328 | }) 329 | 330 | const response = await fastify.inject({ 331 | method: 'GET', 332 | url: '/' 333 | }) 334 | 335 | t.assert.strictEqual(response.statusCode, 500) 336 | t.assert.strictEqual(response.json().message, 'response should be an object with an integer property answer only') 337 | }) 338 | 339 | test('should throw an error if ajv.plugins is not passed to instance and not array', async t => { 340 | t.plan(2) 341 | const fastify = Fastify() 342 | await t.assert.rejects( 343 | async () => fastify.register(plugin, { ajv: { plugins: 'invalid' } }), 344 | (err) => { 345 | t.assert.strictEqual(err.message, 'ajv.plugins option should be an array, instead got \'string\'') 346 | return true 347 | } 348 | ) 349 | }) 350 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const plugin = require('..') 6 | 7 | test('Should return a validation error', async t => { 8 | const fastify = Fastify() 9 | await fastify.register(plugin) 10 | 11 | fastify.route({ 12 | method: 'GET', 13 | url: '/', 14 | schema: { 15 | response: { 16 | '2xx': { 17 | type: 'object', 18 | properties: { 19 | answer: { type: 'number' } 20 | } 21 | } 22 | } 23 | }, 24 | handler: async () => { 25 | return { answer: '42' } 26 | } 27 | }) 28 | 29 | const response = await fastify.inject({ 30 | method: 'GET', 31 | url: '/' 32 | }) 33 | 34 | t.assert.strictEqual(response.statusCode, 500) 35 | const data = response.json() 36 | t.assert.deepStrictEqual(data, { 37 | code: 'FST_RESPONSE_VALIDATION_FAILED_VALIDATION', 38 | statusCode: 500, 39 | error: 'Internal Server Error', 40 | message: 'response/answer must be number' 41 | }) 42 | }) 43 | 44 | test('Should support shortcut schema syntax', async t => { 45 | const fastify = Fastify() 46 | await fastify.register(plugin) 47 | 48 | fastify.route({ 49 | method: 'GET', 50 | url: '/', 51 | schema: { 52 | response: { 53 | '2xx': { 54 | answer: { type: 'number' } 55 | } 56 | } 57 | }, 58 | handler: async () => { 59 | return { answer: '42' } 60 | } 61 | }) 62 | 63 | const response = await fastify.inject({ 64 | method: 'GET', 65 | url: '/' 66 | }) 67 | 68 | t.assert.strictEqual(response.statusCode, 500) 69 | t.assert.deepStrictEqual(JSON.parse(response.payload), { 70 | code: 'FST_RESPONSE_VALIDATION_FAILED_VALIDATION', 71 | statusCode: 500, 72 | error: 'Internal Server Error', 73 | message: 'response/answer must be number' 74 | }) 75 | }) 76 | 77 | test('Should check only the assigned status code', async t => { 78 | const fastify = Fastify() 79 | await fastify.register(plugin) 80 | 81 | fastify.route({ 82 | method: 'GET', 83 | url: '/', 84 | schema: { 85 | response: { 86 | '3xx': { 87 | type: 'object', 88 | properties: { 89 | answer: { type: 'number' } 90 | } 91 | } 92 | } 93 | }, 94 | handler: async () => { 95 | return { answer: '42' } 96 | } 97 | }) 98 | 99 | const response = await fastify.inject({ 100 | method: 'GET', 101 | url: '/' 102 | }) 103 | 104 | t.assert.strictEqual(response.statusCode, 200) 105 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: '42' }) 106 | }) 107 | 108 | /** 109 | * OpenAPI 3.1.0 - Responses Object - 2XX status filter in upper case 110 | * https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responses-object 111 | */ 112 | test('Should use response matching 5XX instead of default', async t => { 113 | const fastify = Fastify() 114 | await fastify.register(plugin) 115 | 116 | fastify.route({ 117 | method: 'GET', 118 | url: '/', 119 | schema: { 120 | response: { 121 | '2XX': { 122 | type: 'object', 123 | properties: { 124 | answer: { type: 'number' } 125 | } 126 | }, 127 | default: { 128 | type: 'object', 129 | properties: { 130 | error_message: { type: 'string' } 131 | }, 132 | required: [ 133 | 'error_message' 134 | ] 135 | } 136 | } 137 | }, 138 | handler: async () => { 139 | return { answer: 42 } 140 | } 141 | }) 142 | 143 | const response = await fastify.inject({ 144 | method: 'GET', 145 | url: '/' 146 | }) 147 | 148 | t.assert.strictEqual(response.statusCode, 200) 149 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 42 }) 150 | }) 151 | 152 | /** 153 | * OpenAPI 3.1.0 - Responses Object - default 154 | * https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responses-object 155 | */ 156 | test('Should fallback to default response if nothing matches', async t => { 157 | const fastify = Fastify() 158 | await fastify.register(plugin) 159 | 160 | fastify.route({ 161 | method: 'GET', 162 | url: '/', 163 | schema: { 164 | response: { 165 | '2XX': { 166 | type: 'object', 167 | properties: { 168 | answer: { type: 'number' } 169 | } 170 | }, 171 | default: { 172 | type: 'object', 173 | properties: { 174 | error_message: { type: 'string' } 175 | }, 176 | required: [ 177 | 'error_message' 178 | ] 179 | } 180 | } 181 | }, 182 | handler: async (_req, reply) => { 183 | reply.status(500).send({ error_message: 'the answer is 42' }) 184 | } 185 | }) 186 | 187 | const response = await fastify.inject({ 188 | method: 'GET', 189 | url: '/' 190 | }) 191 | 192 | t.assert.strictEqual(response.statusCode, 500) 193 | t.assert.deepStrictEqual(JSON.parse(response.payload), { error_message: 'the answer is 42' }) 194 | }) 195 | 196 | test('Should check media types', async t => { 197 | const fastify = Fastify() 198 | await fastify.register(plugin) 199 | 200 | fastify.route({ 201 | method: 'GET', 202 | url: '/', 203 | schema: { 204 | response: { 205 | '2xx': { 206 | content: { 207 | 'application/geo+json': { 208 | schema: { 209 | type: 'object', 210 | properties: { 211 | answer: { type: 'number' } 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | }, 219 | handler: async (_req, reply) => { 220 | reply.header('Content-Type', 'application/not+json') 221 | return { answer: 42 } 222 | } 223 | }) 224 | 225 | const response = await fastify.inject({ 226 | method: 'GET', 227 | url: '/' 228 | }) 229 | 230 | t.assert.strictEqual(response.statusCode, 500) 231 | t.assert.deepStrictEqual(JSON.parse(response.payload), { 232 | code: 'FST_RESPONSE_VALIDATION_SCHEMA_NOT_DEFINED', 233 | statusCode: 500, 234 | error: 'Internal Server Error', 235 | message: 'No schema defined for media type application/not+json' 236 | }) 237 | }) 238 | 239 | test('Should support media types', async t => { 240 | const fastify = Fastify() 241 | await fastify.register(plugin) 242 | 243 | fastify.route({ 244 | method: 'GET', 245 | url: '/', 246 | schema: { 247 | response: { 248 | '2xx': { 249 | content: { 250 | 'application/a+json': { 251 | schema: { 252 | type: 'object', 253 | properties: { 254 | answer: { type: 'boolean' } 255 | } 256 | } 257 | }, 258 | 'application/b+json': { 259 | schema: { 260 | type: 'object', 261 | properties: { 262 | answer: { type: 'number' } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | } 269 | }, 270 | handler: async (_req, reply) => { 271 | reply.header('Content-Type', 'application/b+json') 272 | return { answer: 42 } 273 | } 274 | }) 275 | 276 | const response = await fastify.inject({ 277 | method: 'GET', 278 | url: '/' 279 | }) 280 | 281 | t.assert.strictEqual(response.statusCode, 200) 282 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 42 }) 283 | }) 284 | 285 | test('Should check anyOf Schema', async t => { 286 | const fastify = Fastify() 287 | await fastify.register(plugin) 288 | 289 | fastify.route({ 290 | method: 'GET', 291 | url: '/', 292 | schema: { 293 | response: { 294 | '2xx': { 295 | anyOf: [ 296 | { 297 | type: 'object', 298 | properties: { 299 | answer: { type: 'number' } 300 | } 301 | } 302 | ] 303 | } 304 | } 305 | }, 306 | handler: async () => { 307 | return { answer: '42' } 308 | } 309 | }) 310 | 311 | const response = await fastify.inject({ 312 | method: 'GET', 313 | url: '/' 314 | }) 315 | 316 | t.assert.strictEqual(response.statusCode, 500) 317 | t.assert.strictEqual(JSON.parse(response.payload).error, 'Internal Server Error') 318 | t.assert.strictEqual(JSON.parse(response.payload).message, 'response/answer must be number, response must match a schema in anyOf') 319 | }) 320 | 321 | test('response validation is set, but no response schema given returns unvalidated response', async t => { 322 | const fastify = Fastify() 323 | await fastify.register(plugin) 324 | 325 | fastify.route({ 326 | method: 'GET', 327 | url: '/', 328 | schema: {}, 329 | handler: async () => { 330 | return { answer: '42' } 331 | } 332 | }) 333 | 334 | const response = await fastify.inject({ 335 | method: 'GET', 336 | url: '/' 337 | }) 338 | 339 | t.assert.strictEqual(response.statusCode, 200) 340 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: '42' }) 341 | }) 342 | 343 | test('Override default ajv options', async t => { 344 | const fastify = Fastify() 345 | await fastify.register(plugin, { ajv: { coerceTypes: true } }) 346 | 347 | fastify.route({ 348 | method: 'GET', 349 | url: '/', 350 | schema: { 351 | response: { 352 | '2xx': { 353 | type: 'object', 354 | properties: { 355 | answer: { type: 'number' } 356 | } 357 | } 358 | } 359 | }, 360 | handler: async () => { 361 | return { answer: '42' } 362 | } 363 | }) 364 | 365 | const response = await fastify.inject({ 366 | method: 'GET', 367 | url: '/' 368 | }) 369 | 370 | t.assert.strictEqual(response.statusCode, 200) 371 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 42 }) 372 | }) 373 | 374 | test('Disable response validation for a specific route', async t => { 375 | const fastify = Fastify() 376 | await fastify.register(plugin) 377 | 378 | fastify.route({ 379 | method: 'GET', 380 | url: '/', 381 | responseValidation: false, 382 | schema: { 383 | response: { 384 | '2xx': { 385 | type: 'object', 386 | properties: { 387 | answer: { type: 'number' } 388 | } 389 | } 390 | } 391 | }, 392 | handler: async () => { 393 | return { answer: '42' } 394 | } 395 | }) 396 | 397 | const response = await fastify.inject({ 398 | method: 'GET', 399 | url: '/' 400 | }) 401 | 402 | t.assert.strictEqual(response.statusCode, 200) 403 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 42 }) 404 | }) 405 | 406 | test('Disable response validation for every route', async t => { 407 | const fastify = Fastify() 408 | await fastify.register(plugin, { responseValidation: false }) 409 | 410 | fastify.route({ 411 | method: 'GET', 412 | url: '/', 413 | schema: { 414 | response: { 415 | '2xx': { 416 | type: 'object', 417 | properties: { 418 | answer: { type: 'number' } 419 | } 420 | } 421 | } 422 | }, 423 | handler: async () => { 424 | return { answer: '42' } 425 | } 426 | }) 427 | 428 | const response = await fastify.inject({ 429 | method: 'GET', 430 | url: '/' 431 | }) 432 | 433 | t.assert.strictEqual(response.statusCode, 200) 434 | t.assert.deepStrictEqual(JSON.parse(response.payload), { answer: 42 }) 435 | }) 436 | 437 | test('Enable response status code validation for a specific route', async t => { 438 | const fastify = Fastify() 439 | await fastify.register(plugin) 440 | 441 | fastify.route({ 442 | method: 'GET', 443 | url: '/', 444 | responseStatusCodeValidation: true, 445 | schema: { 446 | response: { 447 | 204: { 448 | type: 'object', 449 | properties: { 450 | answer: { type: 'number' } 451 | } 452 | } 453 | } 454 | }, 455 | handler: async () => { 456 | return { answer: 42 } 457 | } 458 | }) 459 | 460 | const response = await fastify.inject({ 461 | method: 'GET', 462 | url: '/' 463 | }) 464 | 465 | t.assert.strictEqual(response.statusCode, 500) 466 | t.assert.deepStrictEqual(JSON.parse(response.payload), { 467 | code: 'FST_RESPONSE_VALIDATION_SCHEMA_NOT_DEFINED', 468 | statusCode: 500, 469 | error: 'Internal Server Error', 470 | message: 'No schema defined for status code 200' 471 | }) 472 | }) 473 | 474 | test('Enable response status code validation for every route', async t => { 475 | const fastify = Fastify() 476 | await fastify.register(plugin, { responseStatusCodeValidation: true }) 477 | 478 | fastify.route({ 479 | method: 'GET', 480 | url: '/', 481 | schema: { 482 | response: { 483 | 204: { 484 | type: 'object', 485 | properties: { 486 | answer: { type: 'number' } 487 | } 488 | } 489 | } 490 | }, 491 | handler: async () => { 492 | return { answer: '42' } 493 | } 494 | }) 495 | 496 | const response = await fastify.inject({ 497 | method: 'GET', 498 | url: '/' 499 | }) 500 | 501 | t.assert.strictEqual(response.statusCode, 500) 502 | t.assert.deepStrictEqual(JSON.parse(response.payload), { 503 | code: 'FST_RESPONSE_VALIDATION_SCHEMA_NOT_DEFINED', 504 | statusCode: 500, 505 | error: 'Internal Server Error', 506 | message: 'No schema defined for status code 200' 507 | }) 508 | }) 509 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginCallback, RawServerBase, RawServerDefault } from 'fastify' 2 | import Ajv, { Options as AjvOptions } from 'ajv' 3 | 4 | declare module 'fastify' { 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | interface RouteShorthandOptions { 7 | responseValidation?: boolean; 8 | responseStatusCodeValidation?: boolean; 9 | } 10 | } 11 | 12 | type FastifyResponseValidation = FastifyPluginCallback 13 | 14 | declare namespace fastifyResponseValidation { 15 | export interface Options { 16 | ajv?: Ajv | (AjvOptions & { 17 | plugins?: (Function | [Function, unknown])[]; 18 | }); 19 | responseValidation?: boolean; 20 | responseStatusCodeValidation?: boolean; 21 | } 22 | 23 | export const fastifyResponseValidation: FastifyResponseValidation 24 | export { fastifyResponseValidation as default } 25 | } 26 | 27 | declare function fastifyResponseValidation (...params: Parameters): ReturnType 28 | export = fastifyResponseValidation 29 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { 2 | FastifyInstance, 3 | } from 'fastify' 4 | import plugin from '..' 5 | import Ajv from 'ajv' 6 | import Ajv2019 from 'ajv/dist/2019' 7 | import Ajv2020 from 'ajv/dist/2020' 8 | 9 | const app: FastifyInstance = fastify() 10 | app.register(plugin) 11 | app.register(plugin, {}) 12 | app.register(plugin, { ajv: { coerceTypes: true } }) 13 | app.register(plugin, { responseValidation: true }) 14 | app.register(plugin, { responseValidation: false }) 15 | app.register(plugin, { responseStatusCodeValidation: true }) 16 | app.register(plugin, { responseStatusCodeValidation: false }) 17 | app.register(plugin, { ajv: { plugins: [require('ajv-formats')] } }) 18 | app.register(plugin, { ajv: { plugins: [require('ajv-errors')] } }) 19 | app.register(plugin, { ajv: { plugins: [[require('ajv-errors'), {}]] } }) 20 | app.register(plugin, { ajv: { plugins: [require('ajv-formats'), [require('ajv-errors'), {}]] } }) 21 | app.register(plugin, { ajv: new Ajv() }) 22 | app.register(plugin, { ajv: new Ajv2019() }) 23 | app.register(plugin, { ajv: new Ajv2020() }) 24 | 25 | app.route({ 26 | method: 'GET', 27 | url: '/', 28 | responseValidation: false, 29 | schema: { 30 | response: { 31 | '2xx': { 32 | type: 'object', 33 | properties: { 34 | answer: { type: 'number' } 35 | } 36 | } 37 | } 38 | }, 39 | handler: async () => { 40 | return { answer: '42' } 41 | } 42 | }) 43 | --------------------------------------------------------------------------------