diff --git a/.github/ISSUE_TEMPLATE/bug-report---.md b/.github/ISSUE_TEMPLATE/bug-report---.md new file mode 100644 index 0000000..947fa29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report---.md @@ -0,0 +1,38 @@ +--- +name: "Bug report \U0001F41B" +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature-request---.md b/.github/ISSUE_TEMPLATE/feature-request---.md new file mode 100644 index 0000000..350935b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request---.md @@ -0,0 +1,20 @@ +--- +name: "Feature request \U0001F916" +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..d51ce63 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,21 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 15 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - "discussion" + - "feature request" + - "bug" + - "help wanted" + - "plugin suggestion" + - "good first issue" +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e603fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node: [14.x, 16.x, 17.x] + name: Node ${{ matrix.node }} + steps: + - uses: actions/checkout@v1 + - name: Setup node + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + - run: npm install + - run: npm run lint:ci + - run: npm run test:ci + - run: npm run typescript diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbba6f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# package-lock.json +package-lock.json \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1dc4645 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run test:ci diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..48143fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Carlos Fuentes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 857c0fd..082f5c0 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# fastify-split-validator \ No newline at end of file +# fastify-split-validator diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..6fba6ae --- /dev/null +++ b/index.d.ts @@ -0,0 +1,23 @@ +/// +import { FastifyPluginCallback, FastifyContextConfig } from 'fastify'; +import Ajv from 'ajv'; + +interface FastifySplitValidator { + defaultValidator?: Ajv; +} + +declare module 'fastify' { + interface FastifyContextConfig { + schemaValidators: { + body?: Ajv; + headers?: Ajv; + querystring?: Ajv; + query?: Ajv; + params?: Ajv; + }; + } +} + +declare const FastifySplitValidator: FastifyPluginCallback; + +export default FastifySplitValidator; diff --git a/index.js b/index.js new file mode 100644 index 0000000..0e41022 --- /dev/null +++ b/index.js @@ -0,0 +1,83 @@ +'use strict' + +const fp = require('fastify-plugin') +const Ajv = require('ajv') +const ValidatorDictionary = require('./lib/dictionary') +function plugin (fastifyInstance, opts = {}, done) { + let { defaultValidator } = opts + + // validation and more + const dictionary = new ValidatorDictionary() + fastifyInstance.addHook('onRoute', params => { + if (params.config?.schemaValidators != null) { + const { path, method, config } = params + const compilers = config.schemaValidators + const keys = Object.keys(compilers) + + for (const key of keys) { + dictionary.addValidator( + path, + method, + // If query passed, we should change it to querystring + key === 'query' ? 'querystring' : key, + compilers[key] + ) + } + } + }) + + fastifyInstance.setSchemaController({ + compilersFactory: { + // TODO: Maybe the same for serializer? + buildValidator: function (externalSchemas, ajvServerOptions) { + // We load schemas if any + const schemaIds = + externalSchemas != null ? Object.keys(externalSchemas) : [] + defaultValidator = defaultValidator ?? new Ajv(ajvServerOptions) + + if (schemaIds.length > 0) { + const validators = dictionary.getValidators() + + for (const schemaKey of schemaIds) { + const schema = externalSchemas[schemaKey] + for (const validator of validators) { + // Check if schema added or not for custom validators + if (validator.getSchema(schemaKey) == null) { + validator.addSchema(schema, schemaKey) + } + } + + // Also add it to default validator as fallback + if (defaultValidator.getSchema(schemaKey) == null) { + console.log('adding to default validator', schemaKey) + defaultValidator.addSchema(schema, schemaKey) + } + } + } + + return function validatorCompiler ({ schema, method, url, httpPart }) { + const httpPartValidator = dictionary.getValidator( + url, + method, + httpPart + ) + // We compile for cache all schemas for performance + const fallback = defaultValidator.compile(schema) + + if (httpPartValidator == null) { + return fallback + } + + return httpPartValidator.compile(schema) + } + } + } + }) + + done() +} + +module.exports = fp(plugin, { + fastify: '>=3.24.1', + name: 'fastify-split-validator' +}) diff --git a/lib/dictionary.js b/lib/dictionary.js new file mode 100644 index 0000000..ad7fddf --- /dev/null +++ b/lib/dictionary.js @@ -0,0 +1,46 @@ +const Ajv = require('ajv') +const { kSchemas } = require('./symbols') + +function flatSchemas (schemas) { + const keys = Object.keys(schemas) + + if (keys.length === 0) return [] + + let result = [] + for (const key of keys) { + if (schemas[key] instanceof Ajv) { + result.push(schemas[key]) + } else { + const nestedSchemas = flatSchemas(schemas[key]) + result = result.concat(nestedSchemas) + } + } + + return result +} + +module.exports = class ValidatorDictionary { + constructor () { + this[kSchemas] = {} + } + + addValidator (path, method, httpPart, validator) { + if (this[kSchemas][path] == null) { + this[kSchemas][path] = { + [method]: { + [httpPart]: validator + } + } + } else { + this[kSchemas][path][method][httpPart] = validator + } + } + + getValidator (path, method, httpPart) { + return this[kSchemas][path]?.[method]?.[httpPart] + } + + getValidators () { + return flatSchemas(this[kSchemas]) + } +} diff --git a/lib/symbols.js b/lib/symbols.js new file mode 100644 index 0000000..cbc50b8 --- /dev/null +++ b/lib/symbols.js @@ -0,0 +1,3 @@ +module.exports = { + kSchemas: Symbol.for('#schemas') +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..92f3a99 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "t", + "version": "0.0.0", + "description": "Validate each HTTP body message part and Queryparams with different AJV schemas", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "test": "tap --cov test/*.test.js && npm run typescript", + "test:ci": "tap --cov test/*.test.js && npm run typescript && npm run lint", + "test:only": "tap --only", + "test:unit": "tap test/*.test.js", + "lint": "standard | snazzy", + "lint:ci": "standard", + "typescript": "tsd" + }, + "keywords": [ + "fastify", + "ajv", + "validator", + "fastify-split-validator" + ], + "author": "MetCoder95 ", + "license": "MIT", + "devDependencies": { + "@types/node": "^14.17.6", + "fastify": "^3.24.1", + "husky": "^7.0.2", + "proxyquire": "^2.1.3", + "snazzy": "^9.0.0", + "standard": "^16.0.3", + "tap": "^15.0.10", + "tsd": "^0.17.0", + "typescript": "^4.4" + }, + "dependencies": { + "ajv": "^8.6.3", + "fastify-plugin": "^3.0.0" + }, + "tsd": { + "directory": "test" + }, + "tap": { + "check-coverage": false + }, + "standard": { + "ignore": [ + "*.d.ts", + "*.test-d.ts" + ] + } +} diff --git a/test/index.test-d.ts b/test/index.test-d.ts new file mode 100644 index 0000000..dac68a6 --- /dev/null +++ b/test/index.test-d.ts @@ -0,0 +1,136 @@ +import fastify from 'fastify' +import Ajv from 'ajv' +import plugin from '..' + +const serverHttp = fastify() +const customAjv = new Ajv() + +serverHttp.register(plugin) + +serverHttp.addSchema({ + $id: 'hello', + type: 'string' +}) + +serverHttp.register(plugin, { + defaultValidator: customAjv +}) + +serverHttp.get('/', { + schema: { + querystring: { + hello: { + $ref: 'hello#' + } + } + }, + config: { + schemaValidators: { + querystring: customAjv + } + } +} , async (request, reply) => {}) + + +// -> Second level +serverHttp.register( + function (fastifyInstance, opts, done) { + fastifyInstance.register(plugin) + + fastifyInstance.get('/string',{ + schema:{ + params: { + hello: { + $ref: 'hello#', + } + }, + response:{ + 200:{ + type:'string' + } + }, + }, + config: { + schemaValidators: { + params: customAjv + } + } + }, (req, reply) => { + reply.send({ + hello: 'world' + }) + }) + + // Sending a JSON + serverHttp.post('/json', (req, reply) => { + reply.send({ foo: 'bar' }) + }) + + + done() + }, + { prefix: '/api' } +) + +const serverHttp2 = fastify({ http2: true }) +serverHttp2.addSchema({ + $id: 'hello', + type: 'string' +}) + + +serverHttp2.register(plugin, { + defaultValidator: new Ajv() +}) + +serverHttp2.get('/', { + schema: { + querystring: { + hello: { + $ref: 'hello#' + } + } + }, + config: { + schemaValidators: { + querystring: customAjv + } + } +} , async (request, reply) => {}) + +// -> First plugin +serverHttp2.register( + function (fastifyInstance, opts, done) { + fastifyInstance.get('/string',{ + schema:{ + params: { + hello: { + $ref: 'hello#', + } + }, + response:{ + 200:{ + type:'string' + } + }, + }, + config: { + schemaValidators: { + params: customAjv + } + } + }, (req, reply) => { + reply.send({ + hello: 'world' + }) + }) + + // Sending a JSON + fastifyInstance.post('/json', (req, reply) => { + reply.send({ foo: 'bar' }) + }) + + done() + }, + { prefix: '/api' } +) diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..2353413 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,957 @@ +'use strict' + +const tap = require('tap') +const AJV = require('ajv') +const Fastify = require('fastify') +const proxyquire = require('proxyquire') +const plugin = require('..') + +const test = tap.test + +tap.plan(15) + +test('Should allow custom AJV instance for querystring', async t => { + t.plan(1) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.register(plugin, {}) + + server.get( + '/', + { + schema: { + querystring: { + msg: { + type: 'array', + items: { + type: 'string' + } + } + } + }, + config: { + schemaValidators: { + // TODO: normalize to query + querystring: customAjv + } + } + }, + (req, reply) => {} + ) + + try { + const res = await server.inject({ + method: 'GET', + url: '/', + query: { + msg: ['hello world'] + } + }) + + t.equal( + res.statusCode, + 400, + 'Should coerce the single element array into string' + ) + } catch (err) { + t.error(err) + } +}) + +test('Should allow custom AJV instance for body', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.register(plugin, {}) + + server.post( + '/', + { + schema: { + body: { + type: 'object', + properties: { + msg: { + type: 'array', + items: { + type: 'string' + } + } + } + } + }, + config: { + schemaValidators: { + body: customAjv + } + } + }, + (req, reply) => {} + ) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + payload: { + msg: 'hello world' + } + }) + + const body = res.json() + + t.equal(body.message, 'body must be array') + t.equal( + res.statusCode, + 400, + 'Should coerce the single element array into string' + ) + } catch (err) { + t.error(err) + } +}) + +test('Should allow custom AJV instance for params', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.register(plugin, {}) + + server.get( + '/:msg', + { + schema: { + params: { + msg: { + type: 'integer' + } + } + }, + config: { + schemaValidators: { + params: customAjv + } + } + }, + (req, reply) => {} + ) + + try { + const res = await server.inject({ + method: 'GET', + url: '/1' + }) + + const body = res.json() + + t.equal(body.message, 'params must be integer') + t.equal( + res.statusCode, + 400, + 'Should coerce the single element array into string' + ) + } catch (err) { + t.error(err) + } +}) + +test('Should allow custom AJV instance for headers', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.register(plugin, {}) + + server.get( + '/', + { + schema: { + headers: { + 'x-type': { + type: 'integer' + } + } + }, + config: { + schemaValidators: { + headers: customAjv + } + } + }, + (req, reply) => {} + ) + + try { + const res = await server.inject({ + method: 'GET', + path: '/', + headers: { + 'x-type': '1' + } + }) + + const body = res.json() + + // TODO: set into documentation that it's possible the + // error formatter doesn't work as expected. + // Custom one should be provided + t.equal(body.message, 'headers must be integer') + t.equal( + res.statusCode, + 400, + 'Should coerce the single element array into string' + ) + } catch (err) { + t.error(err) + } +}) + +test('Should work with referenced schemas (querystring)', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register(plugin, {}) + + // The issue is at the `Fastify#setSchemaControler` level, + // as when adding a new SchemaController the parent is passed + // instead of the same old Schema Controller, causing + // to lose the reference to the prior registered Schemas. + // Reported at: https://github.com/fastify/fastify/issues/3121 + server.get( + '/', + { + schema: { + query: { + msg: { + $ref: 'some#' + } + } + }, + config: { + schemaValidators: { + querystring: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + try { + const res = await server.inject({ + method: 'GET', + url: '/', + query: { + msg: ['hello world'] + } + }) + + const body = res.json() + + t.equal(body.message, 'querystring must be array') + t.equal( + res.statusCode, + 400, + 'Should parse the single element array into string' + ) + } catch (err) { + t.error(err) + } +}) + +test('Should work with referenced schemas (params)', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'integer' + }) + + server.register(plugin, {}) + + server.get( + '/:id', + { + schema: { + params: { + id: { + $ref: 'some#' + } + } + }, + config: { + schemaValidators: { + params: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + try { + const res = await server.inject({ + method: 'GET', + url: '/1' + }) + + const body = res.json() + + t.equal(body.message, 'params must be integer') + t.equal(res.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should work with referenced schemas (headers)', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'integer' + }) + + server.register(plugin, {}) + + server.get( + '/', + { + schema: { + headers: { + 'x-id': { + $ref: 'some#' + } + } + }, + config: { + schemaValidators: { + headers: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + try { + const res = await server.inject({ + method: 'GET', + url: '/', + headers: { + 'x-id': '1' + } + }) + + const body = res.json() + + t.equal(body.message, 'headers must be integer') + t.equal(res.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) +test('Should work with referenced schemas (body)', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'string' + }) + + server.register(plugin, {}) + + server.post( + '/', + { + schema: { + body: { + type: 'object', + properties: { + msg: { + $ref: 'some#' + } + } + } + }, + config: { + schemaValidators: { + body: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + payload: { + msg: 1 + } + }) + + const body = res.json() + + t.equal(body.message, 'body must be string') + t.equal(res.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should work with parent and same instance schemas', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'string' + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'string' + }) + + // #TODO: Another bug, schemas defined within the same + // encaptulated plugin are not being registered + instance.register(plugin, {}) + + instance.post( + '/', + { + schema: { + body: { + type: 'object', + properties: { + msg: { + $ref: 'some#' + }, + another: { + $ref: 'another#' + } + } + } + }, + config: { + schemaValidators: { + body: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + done() + }) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + payload: { + msg: 1 + } + }) + + const body = res.json() + + t.equal(body.message, 'body must be string') + t.equal(res.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should work with parent schemas', async t => { + t.plan(2) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'string' + }) + + server.register((instance, opts, done) => { + instance.register(plugin, {}) + + instance.post( + '/', + { + schema: { + body: { + type: 'object', + properties: { + msg: { + $ref: 'some#' + } + } + } + }, + config: { + schemaValidators: { + body: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + done() + }) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + payload: { + msg: 1 + } + }) + + const body = res.json() + + t.equal(body.message, 'body must be string') + t.equal(res.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should work with parent nested schemas', async t => { + t.plan(4) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'integer' + }) + + instance.register((subInstance, opts, innerDone) => { + subInstance.register(plugin, {}) + + subInstance.post( + '/', + { + schema: { + querystring: { + msg: { + $ref: 'some#' + } + }, + headers: { + 'x-another': { + $ref: 'another#' + } + } + }, + config: { + schemaValidators: { + querystring: customAjv, + headers: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + innerDone() + }) + + done() + }) + + try { + const [res1, res2] = await Promise.all([ + server.inject({ + method: 'POST', + url: '/', + query: { + msg: ['string'] + } + }), + server.inject({ + method: 'POST', + url: '/', + headers: { + 'x-another': '1' + } + }) + ]) + + t.equal(res1.json().message, 'querystring must be array') + t.equal(res1.statusCode, 400, 'Should not coearce the string into array') + t.equal(res2.json().message, 'headers must be integer') + t.equal(res2.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should handle parsing to querystring (query)', async t => { + t.plan(4) + const customAjv = new AJV({ coerceTypes: false }) + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'integer' + }) + + instance.register((subInstance, opts, innerDone) => { + subInstance.register(plugin, {}) + + subInstance.post( + '/', + { + schema: { + query: { + msg: { + $ref: 'some#' + } + }, + headers: { + 'x-another': { + $ref: 'another#' + } + } + }, + config: { + schemaValidators: { + query: customAjv, + headers: customAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + innerDone() + }) + + done() + }) + + try { + const [res1, res2] = await Promise.all([ + server.inject({ + method: 'POST', + url: '/', + query: { + msg: ['string'] + } + }), + server.inject({ + method: 'POST', + url: '/', + headers: { + 'x-another': '1' + } + }) + ]) + + t.equal(res1.json().message, 'querystring must be array') + t.equal(res1.statusCode, 400, 'Should not coearce the string into array') + t.equal(res2.json().message, 'headers must be integer') + t.equal(res2.statusCode, 400, 'Should not coearce the string into integer') + } catch (err) { + t.error(err) + } +}) + +test('Should use default plugin validator as fallback', async t => { + t.plan(3) + let compileCalled = false + const defaultAjv = new AJV({ coerceTypes: false }) + const defaultCompile = defaultAjv.compile.bind(defaultAjv) + + defaultAjv.compile = schema => { + compileCalled = true + return defaultCompile(schema) + } + + const proxiedPlugin = proxyquire('..', { + ajv: class { + constructor () { + return defaultAjv + } + } + }) + + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'integer' + }) + + instance.register(proxiedPlugin, {}) + + instance.post( + '/', + { + schema: { + query: { + msg: { + $ref: 'some#' + } + }, + headers: { + 'x-another': { + $ref: 'another#' + } + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + done() + }) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + query: { + msg: ['string'] + } + }) + + t.equal(res.json().message, 'querystring must be array') + t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.ok(compileCalled, 'Should have called the default Ajv instance') + } catch (err) { + t.error(err) + } +}) + +test('Should always cache schema to default plugin validator', async t => { + t.plan(4) + let compileCalled = false + let customCompileCalled = false + const defaultAjv = new AJV({ coerceTypes: false }) + const headerAjv = new AJV({ coerceTypes: false }) + const defaultCompile = defaultAjv.compile.bind(defaultAjv) + const headerDefaultCompile = headerAjv.compile.bind(headerAjv) + + defaultAjv.compile = schema => { + compileCalled = true + return defaultCompile(schema) + } + + headerAjv.compile = schema => { + customCompileCalled = true + return headerDefaultCompile(schema) + } + + const proxiedPlugin = proxyquire('..', { + ajv: class { + constructor () { + return defaultAjv + } + } + }) + + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'integer' + }) + + instance.register(proxiedPlugin, {}) + + instance.post( + '/', + { + schema: { + query: { + msg: { + $ref: 'some#' + } + }, + headers: { + 'x-another': { + $ref: 'another#' + } + } + }, + config: { + schemaValidators: { + headers: headerAjv + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + done() + }) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + query: { + msg: ['string'] + } + }) + + t.equal(res.json().message, 'querystring must be array') + t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.ok(compileCalled, 'Should have called the default Ajv instance') + t.ok(customCompileCalled, 'Should have called the custom Ajv instance') + } catch (err) { + t.error(err) + } +}) + +test('Should use default provided validator as fallback', async t => { + t.plan(3) + let compileCalled = false + const defaultAjv = new AJV({ coerceTypes: false }) + const defaultCompile = defaultAjv.compile.bind(defaultAjv) + + defaultAjv.compile = schema => { + compileCalled = true + return defaultCompile(schema) + } + + const server = Fastify() + + server.addSchema({ + $id: 'some', + type: 'array', + items: { + type: 'string' + } + }) + + server.register((instance, opts, done) => { + instance.addSchema({ + $id: 'another', + type: 'integer' + }) + + instance.register(plugin, { + defaultValidator: defaultAjv + }) + + instance.post( + '/', + { + schema: { + query: { + msg: { + $ref: 'some#' + } + }, + headers: { + 'x-another': { + $ref: 'another#' + } + } + } + }, + (req, reply) => { + reply.send({ noop: 'noop' }) + } + ) + + done() + }) + + try { + const res = await server.inject({ + method: 'POST', + url: '/', + query: { + msg: ['string'] + } + }) + + t.equal(res.json().message, 'querystring must be array') + t.equal(res.statusCode, 400, 'Should not coearce the string into array') + t.ok(compileCalled, 'Should have called the default Ajv instance') + } catch (err) { + t.error(err) + } +})