From 0a47189ec6d184f64745c22c6329b1e236280b91 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 20 Oct 2021 00:33:44 +0200 Subject: [PATCH 01/21] chore: initial setup --- .github/ISSUE_TEMPLATE/bug-report---.md | 38 +++++++ .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++ .github/ISSUE_TEMPLATE/feature-request---.md | 20 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++ .github/stale.yml | 21 ++++ .github/workflows/ci.yml | 20 ++++ .gitignore | 107 +++++++++++++++++++ .husky/pre-commit | 4 + LICENSE | 21 ++++ README.md | 2 +- package.json | 41 +++++++ 11 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report---.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature-request---.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/stale.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 LICENSE create mode 100644 package.json 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..2a97189 --- /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: [10.x, 12.x, 14.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..4146d79 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm test && npm lint 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/package.json b/package.json new file mode 100644 index 0000000..c89ce32 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "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", + "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.21.6", + "husky": "^7.0.2", + "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" + } +} From dd4a12d353c89921f805b31491432b5da0e2168d Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 20 Oct 2021 00:34:22 +0200 Subject: [PATCH 02/21] feat!:initial version --- index.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++ lib/dictionary.js | 19 +++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 index.js create mode 100644 lib/dictionary.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..3178140 --- /dev/null +++ b/index.js @@ -0,0 +1,59 @@ +'use strict' + +const fp = require('fastify-plugin') +const Ajv = require('ajv') +const ValidatorDictionary = require('./lib/dictionary') + +// TODO: add default validator +// TODO: allow AJV passed by the instance#register function +// TODO: ts types +function plugin (fastifyInstance, opts, done) { + // validation and more + const validatorInstance = new ValidatorDictionary() + + // TODO: Make it work with the #bucket: + /** + * 1. Because is needed to resolve $ref statements + * 2. For do a fallback if not config.schemaBuilders is set + * 3. For each new instance, the parent schemas are passed and needs to be resolved + */ + + fastifyInstance.addHook('onRoute', params => { + if (params.config && params.config.schemaBuilders) { + const { path, method, config } = params + const builders = config.schemaBuilders + const keys = Object.keys(builders) + + for (const key of keys) { + validatorInstance.addValidator(path, method, key, builders[key]) + } + } + }) + + fastifyInstance.setSchemaController({ + compilersFactory: { + // TODO: Maybe the same for serializer? + buildValidator: function (externalSchemas, ajvServerOptions) { + return function validatorCompiler ({ schema, method, url, httpPart }) { + // console.log(schema, method, url, httpPart) // #Debug + const validator = validatorInstance.getValidator(url, method, httpPart) ?? new Ajv(ajvServerOptions) + + // We load schemas if any + if (externalSchemas) { + for (const key of Object.keys(externalSchemas)) { + validator.addSchema(externalSchemas[key], key) + } + } + + return validator.compile(schema) + } + } + } + }) + + done() +} + +module.exports = fp(plugin, { + fastify: '>=3.21.0' +}) diff --git a/lib/dictionary.js b/lib/dictionary.js new file mode 100644 index 0000000..7b00923 --- /dev/null +++ b/lib/dictionary.js @@ -0,0 +1,19 @@ +module.exports = class ValidatorDictionary { + constructor () { + this.instances = {} + } + + addValidator (path, method, httpPart, validator) { + if (this.instances[path] == null) { + this.instances[path] = { + [method]: { + [httpPart]: validator + } + } + } + } + + getValidator (path, method, httpPart) { + return this.instances[path]?.[method]?.[httpPart] + } +} From eb72f27c4bab71f5eedefcd20ab3142ade631019 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 20 Oct 2021 00:34:44 +0200 Subject: [PATCH 03/21] test: add testing --- test/index.test.js | 160 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/index.test.js diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..ee1d07e --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,160 @@ +'use strict' + +const tap = require('tap') +const AJV = require('ajv') +const Fastify = require('fastify') +const plugin = require('..') + +const test = tap.test + +// tap.plan(2) + +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: { + schemaBuilders: { + 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: { + schemaBuilders: { + 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', { only: true } , 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: { + schemaBuilders: { + 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) + } +}) From 58ac8243bc3c3f7e6615abbc8e2d9327d88c53bd Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 21 Oct 2021 22:21:54 +0200 Subject: [PATCH 04/21] test: disable check for 100% coverage --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index c89ce32..b0b610e 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ }, "tsd": { "directory": "test" + }, + "tap": { + "check-coverage": false } } From 7e35ec65b0776183d688cb0ca29c97aa0f758946 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 21 Oct 2021 22:22:14 +0200 Subject: [PATCH 05/21] chore: fix ci --- .husky/pre-commit | 2 +- package.json | 2 +- test/index.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 4146d79..1dc4645 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm test && npm lint +npm run test:ci diff --git a/package.json b/package.json index b0b610e..1dcc196 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "index.d.ts", "scripts": { "test": "tap --cov test/*.test.js && npm run typescript", - "test:ci": "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", diff --git a/test/index.test.js b/test/index.test.js index ee1d07e..e1ca7a1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -113,7 +113,7 @@ test('Should allow custom AJV instance for body', async t => { } }) -test('Should allow custom AJV instance for params', { only: true } , async t => { +test('Should allow custom AJV instance for params', async t => { t.plan(2) const customAjv = new AJV({ coerceTypes: false }) const server = Fastify() From b88ad091c91769329cd961e89a36d58e372ae0ac Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 28 Oct 2021 17:51:52 +0200 Subject: [PATCH 06/21] feat: add Dictionary of validators --- index.js | 44 ++++++++++++++++++++++++++------------------ lib/dictionary.js | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/index.js b/index.js index 3178140..1a2e39c 100644 --- a/index.js +++ b/index.js @@ -8,24 +8,17 @@ const ValidatorDictionary = require('./lib/dictionary') // TODO: allow AJV passed by the instance#register function // TODO: ts types function plugin (fastifyInstance, opts, done) { + let { defaultValidator } = opts // validation and more - const validatorInstance = new ValidatorDictionary() - - // TODO: Make it work with the #bucket: - /** - * 1. Because is needed to resolve $ref statements - * 2. For do a fallback if not config.schemaBuilders is set - * 3. For each new instance, the parent schemas are passed and needs to be resolved - */ - + const dictionary = new ValidatorDictionary() fastifyInstance.addHook('onRoute', params => { - if (params.config && params.config.schemaBuilders) { + if (params.config?.schemaBuilders != null) { const { path, method, config } = params const builders = config.schemaBuilders const keys = Object.keys(builders) for (const key of keys) { - validatorInstance.addValidator(path, method, key, builders[key]) + dictionary.addValidator(path, method, key, builders[key]) } } }) @@ -34,16 +27,31 @@ function plugin (fastifyInstance, opts, done) { compilersFactory: { // TODO: Maybe the same for serializer? buildValidator: function (externalSchemas, ajvServerOptions) { - return function validatorCompiler ({ schema, method, url, httpPart }) { - // console.log(schema, method, url, httpPart) // #Debug - const validator = validatorInstance.getValidator(url, method, httpPart) ?? new Ajv(ajvServerOptions) + // We load schemas if any + const schemaIds = + externalSchemas != null ? Object.keys(externalSchemas) : [] + defaultValidator = defaultValidator != null ? defaultValidator : new Ajv(ajvServerOptions) + + if (schemaIds.length > 0) { + const validators = dictionary.getValidators() - // We load schemas if any - if (externalSchemas) { - for (const key of Object.keys(externalSchemas)) { - validator.addSchema(externalSchemas[key], key) + for (const schemaKey of schemaIds) { + for (const validator of validators) { + // Check if schema added or not + if (validator.getSchema(schemaKey) == null) { + validator.addSchema(externalSchemas[schemaKey], schemaKey) + } + + if (defaultValidator.getSchema(schemaKey) == null) { + defaultValidator.addSchema(externalSchemas[schemaKey], schemaKey) + } } } + } + + return function validatorCompiler ({ schema, method, url, httpPart }) { + const dictionaryValidator = dictionary.getValidator(url, method, httpPart) + const validator = dictionaryValidator == null ? dictionaryValidator : defaultValidator return validator.compile(schema) } diff --git a/lib/dictionary.js b/lib/dictionary.js index 7b00923..ad7fddf 100644 --- a/lib/dictionary.js +++ b/lib/dictionary.js @@ -1,19 +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.instances = {} + this[kSchemas] = {} } addValidator (path, method, httpPart, validator) { - if (this.instances[path] == null) { - this.instances[path] = { + if (this[kSchemas][path] == null) { + this[kSchemas][path] = { [method]: { [httpPart]: validator } } + } else { + this[kSchemas][path][method][httpPart] = validator } } getValidator (path, method, httpPart) { - return this.instances[path]?.[method]?.[httpPart] + return this[kSchemas][path]?.[method]?.[httpPart] + } + + getValidators () { + return flatSchemas(this[kSchemas]) } } From 954389ca06270b615dabc0b6b86aea8c6e16944d Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 28 Oct 2021 17:52:59 +0200 Subject: [PATCH 07/21] test: improve testing --- test/index.test.js | 452 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 428 insertions(+), 24 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index e1ca7a1..684dfac 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -31,22 +31,22 @@ test('Should allow custom AJV instance for querystring', async t => { }, config: { schemaBuilders: { + // TODO: normalize to query querystring: customAjv } } }, - (req, reply) => { } + (req, reply) => {} ) try { - const res = await server.inject( - { - method: 'GET', - url: '/', - query: { - msg: ['hello world'] - } - }) + const res = await server.inject({ + method: 'GET', + url: '/', + query: { + msg: ['hello world'] + } + }) t.equal( res.statusCode, @@ -87,18 +87,17 @@ test('Should allow custom AJV instance for body', async t => { } } }, - (req, reply) => { } + (req, reply) => {} ) try { - const res = await server.inject( - { - method: 'POST', - url: '/', - payload: { - msg: 'hello world' - } - }) + const res = await server.inject({ + method: 'POST', + url: '/', + payload: { + msg: 'hello world' + } + }) const body = res.json() @@ -136,15 +135,14 @@ test('Should allow custom AJV instance for params', async t => { } } }, - (req, reply) => { } + (req, reply) => {} ) try { - const res = await server.inject( - { - method: 'GET', - url: '/1' - }) + const res = await server.inject({ + method: 'GET', + url: '/1' + }) const body = res.json() @@ -158,3 +156,409 @@ test('Should allow custom AJV instance for params', async t => { 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: { + schemaBuilders: { + 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: { + schemaBuilders: { + 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)', { only: true }, 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: { + schemaBuilders: { + 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: { + schemaBuilders: { + 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: { + schemaBuilders: { + 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 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: { + schemaBuilders: { + 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#' // I cannot find #another schema + } + } + }, + config: { + schemaBuilders: { + 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) + } +}) From 17fee31cecfe8cc6cd63c032528d905c9a527286 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 28 Oct 2021 17:57:44 +0200 Subject: [PATCH 08/21] chore: add missing simbols --- lib/symbols.js | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 lib/symbols.js 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') +} From c579f3b8773a2884fbb918192dbcee0e5bb9c92f Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 28 Oct 2021 18:28:22 +0200 Subject: [PATCH 09/21] feat!: rename to schemaValidators --- index.js | 5 +++-- test/index.test.js | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 1a2e39c..57b31d7 100644 --- a/index.js +++ b/index.js @@ -7,14 +7,15 @@ const ValidatorDictionary = require('./lib/dictionary') // TODO: add default validator // TODO: allow AJV passed by the instance#register function // TODO: ts types +// TODO: normalize query/querystring function plugin (fastifyInstance, opts, done) { let { defaultValidator } = opts // validation and more const dictionary = new ValidatorDictionary() fastifyInstance.addHook('onRoute', params => { - if (params.config?.schemaBuilders != null) { + if (params.config?.schemaValidators != null) { const { path, method, config } = params - const builders = config.schemaBuilders + const builders = config.schemaValidators const keys = Object.keys(builders) for (const key of keys) { diff --git a/test/index.test.js b/test/index.test.js index 684dfac..b3de868 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -30,7 +30,7 @@ test('Should allow custom AJV instance for querystring', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { // TODO: normalize to query querystring: customAjv } @@ -82,7 +82,7 @@ test('Should allow custom AJV instance for body', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { body: customAjv } } @@ -130,7 +130,7 @@ test('Should allow custom AJV instance for params', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { params: customAjv } } @@ -175,7 +175,7 @@ test('Should allow custom AJV instance for headers', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { headers: customAjv } } @@ -239,7 +239,7 @@ test('Should work with referenced schemas (querystring)', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { querystring: customAjv } } @@ -294,7 +294,7 @@ test('Should work with referenced schemas (params)', { only: true }, async t => } }, config: { - schemaBuilders: { + schemaValidators: { params: customAjv } } @@ -342,7 +342,7 @@ test('Should work with referenced schemas (headers)', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { headers: customAjv } } @@ -395,7 +395,7 @@ test('Should work with referenced schemas (body)', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { body: customAjv } } @@ -450,7 +450,7 @@ test('Should work with parent schemas', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { body: customAjv } } @@ -519,7 +519,7 @@ test('Should work with parent nested schemas', async t => { } }, config: { - schemaBuilders: { + schemaValidators: { querystring: customAjv, headers: customAjv } From bc941e06aa8feed0687e13cdda9a5e578559babe Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 12 Nov 2021 00:03:27 +0100 Subject: [PATCH 10/21] test: update tests --- test/index.test.js | 233 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 2 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index b3de868..9f05591 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,6 +3,7 @@ const tap = require('tap') const AJV = require('ajv') const Fastify = require('fastify') +const proxyquire = require('proxyquire') const plugin = require('..') const test = tap.test @@ -271,7 +272,7 @@ test('Should work with referenced schemas (querystring)', async t => { } }) -test('Should work with referenced schemas (params)', { only: true }, async t => { +test('Should work with referenced schemas (params)', async t => { t.plan(2) const customAjv = new AJV({ coerceTypes: false }) const server = Fastify() @@ -423,6 +424,74 @@ test('Should work with referenced schemas (body)', async t => { } }) +test('Should work with parent and same instance schemas', { todo: true }, 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 }) @@ -514,7 +583,7 @@ test('Should work with parent nested schemas', async t => { }, headers: { 'x-another': { - $ref: 'another#' // I cannot find #another schema + $ref: 'another#' } } }, @@ -562,3 +631,163 @@ test('Should work with parent nested schemas', async t => { 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 throw if not default validator passed', { todo: true }, async t => { + t.plan(4) + 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) + } +}) From 8c10a81438d8065fb32aba6a2c5e7603a8cb396f Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 12 Nov 2021 00:03:58 +0100 Subject: [PATCH 11/21] chore(deps): update fastify --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1dcc196..debdda9 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "license": "MIT", "devDependencies": { "@types/node": "^14.17.6", - "fastify": "^3.21.6", + "fastify": "^3.23.1", "husky": "^7.0.2", + "proxyquire": "^2.1.3", "snazzy": "^9.0.0", "standard": "^16.0.3", "tap": "^15.0.10", From 85b9ec3fcafa849f724ba630828bf9c908c503fe Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 12 Nov 2021 00:04:24 +0100 Subject: [PATCH 12/21] chore: setup standard rules --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index debdda9..b2e193e 100644 --- a/package.json +++ b/package.json @@ -41,5 +41,11 @@ }, "tap": { "check-coverage": false + }, + "standard": { + "ignore": [ + "*.d.ts", + "*.test-d.ts" + ] } } From b98cea6c4ba943fc7a6f34017755c7b54f1313a9 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 12 Nov 2021 00:05:17 +0100 Subject: [PATCH 13/21] refactor: small refactoring --- index.js | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/index.js b/index.js index 57b31d7..dab7a92 100644 --- a/index.js +++ b/index.js @@ -3,20 +3,16 @@ const fp = require('fastify-plugin') const Ajv = require('ajv') const ValidatorDictionary = require('./lib/dictionary') - -// TODO: add default validator -// TODO: allow AJV passed by the instance#register function -// TODO: ts types -// TODO: normalize query/querystring -function plugin (fastifyInstance, opts, done) { +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 builders = config.schemaValidators - const keys = Object.keys(builders) + const compilers = config.schemaValidators + const keys = Object.keys(compilers) for (const key of keys) { dictionary.addValidator(path, method, key, builders[key]) @@ -31,28 +27,36 @@ function plugin (fastifyInstance, opts, done) { // We load schemas if any const schemaIds = externalSchemas != null ? Object.keys(externalSchemas) : [] - defaultValidator = defaultValidator != null ? defaultValidator : new Ajv(ajvServerOptions) + 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 + // Check if schema added or not for custom validators if (validator.getSchema(schemaKey) == null) { - validator.addSchema(externalSchemas[schemaKey], schemaKey) + validator.addSchema(schema, schemaKey) } + } - if (defaultValidator.getSchema(schemaKey) == null) { - defaultValidator.addSchema(externalSchemas[schemaKey], 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 dictionaryValidator = dictionary.getValidator(url, method, httpPart) - const validator = dictionaryValidator == null ? dictionaryValidator : defaultValidator + const httpPartValidator = dictionary.getValidator( + url, + method, + httpPart + ) + const validator = + httpPartValidator == null ? defaultValidator : httpPartValidator return validator.compile(schema) } @@ -64,5 +68,6 @@ function plugin (fastifyInstance, opts, done) { } module.exports = fp(plugin, { - fastify: '>=3.21.0' + fastify: '>=3.23.1', + name: 'fastify-split-validator' }) From cc66115f79642cea4297733ae8ccf0daab89bc2e Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 12 Nov 2021 00:05:42 +0100 Subject: [PATCH 14/21] feat: parse query into querystring --- index.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index dab7a92..1f889b1 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,13 @@ function plugin (fastifyInstance, opts = {}, done) { const keys = Object.keys(compilers) for (const key of keys) { - dictionary.addValidator(path, method, key, builders[key]) + dictionary.addValidator( + path, + method, + // If query passed, we should change it to querystring + key === 'query' ? 'querystring' : key, + compilers[key] + ) } } }) From db6a0d134b376300f4539d38561a96d4143a8f15 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 23 Nov 2021 23:31:39 +0100 Subject: [PATCH 15/21] test: improve testing --- test/index.test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 3 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index 9f05591..2353413 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -8,7 +8,7 @@ const plugin = require('..') const test = tap.test -// tap.plan(2) +tap.plan(15) test('Should allow custom AJV instance for querystring', async t => { t.plan(1) @@ -424,7 +424,7 @@ test('Should work with referenced schemas (body)', async t => { } }) -test('Should work with parent and same instance schemas', { todo: true }, async t => { +test('Should work with parent and same instance schemas', async t => { t.plan(2) const customAjv = new AJV({ coerceTypes: false }) const server = Fastify() @@ -714,17 +714,103 @@ test('Should handle parsing to querystring (query)', async t => { } }) -test('Should throw if not default validator passed', { todo: true }, async t => { +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 () { @@ -751,6 +837,84 @@ test('Should throw if not default validator passed', { todo: true }, async t => 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( '/', { From 4ad9fd673bbfbdeb93b7762283624d40bebfc48a Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 23 Nov 2021 23:32:15 +0100 Subject: [PATCH 16/21] feat: compile always to default ajv schema --- index.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 1f889b1..b19c4e9 100644 --- a/index.js +++ b/index.js @@ -61,10 +61,14 @@ function plugin (fastifyInstance, opts = {}, done) { method, httpPart ) - const validator = - httpPartValidator == null ? defaultValidator : httpPartValidator + // We compile for cache all schemas for performance + const fallback = defaultValidator.compile(schema) - return validator.compile(schema) + if (httpPartValidator == null) { + return fallback + } + + return httpPartValidator.compile(schema) } } } From 619ca6d679de0ac588af2cec77d10ba29d182847 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 23 Nov 2021 23:32:52 +0100 Subject: [PATCH 17/21] chore(deps): point momentary to main fastify version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2e193e..d792642 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^14.17.6", - "fastify": "^3.23.1", + "fastify": "fastify/fastify#main", "husky": "^7.0.2", "proxyquire": "^2.1.3", "snazzy": "^9.0.0", From cbfa21cf2c43d1cf58680bad193f047e698cdfe4 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 23 Nov 2021 23:42:59 +0100 Subject: [PATCH 18/21] feat: add ts types --- index.d.ts | 23 ++++++++ test/index.test-d.ts | 136 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 index.d.ts create mode 100644 test/index.test-d.ts 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/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' } +) From 8e8de22022fbe74b7c8017e2ef81c64bf47db6a8 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 7 Dec 2021 19:03:23 -0800 Subject: [PATCH 19/21] chore: update CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a97189..8e603fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [10.x, 12.x, 14.x] + node: [14.x, 16.x, 17.x] name: Node ${{ matrix.node }} steps: - uses: actions/checkout@v1 @@ -15,6 +15,6 @@ jobs: with: node-version: ${{ matrix.node }} - run: npm install - - run: npm run lint-ci - - run: npm run test-ci + - run: npm run lint:ci + - run: npm run test:ci - run: npm run typescript From 066862f5f5671dc9d022bd18bb2c1a420ccfce17 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 7 Dec 2021 19:03:39 -0800 Subject: [PATCH 20/21] chore: update fastify --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d792642..92f3a99 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test:only": "tap --only", "test:unit": "tap test/*.test.js", "lint": "standard | snazzy", - "lint-ci": "standard", + "lint:ci": "standard", "typescript": "tsd" }, "keywords": [ @@ -23,7 +23,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^14.17.6", - "fastify": "fastify/fastify#main", + "fastify": "^3.24.1", "husky": "^7.0.2", "proxyquire": "^2.1.3", "snazzy": "^9.0.0", From c0bcb5012f162c0d53631a9468a996b8fde48f07 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Tue, 7 Dec 2021 19:06:40 -0800 Subject: [PATCH 21/21] fix: update minimum required version --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index b19c4e9..0e41022 100644 --- a/index.js +++ b/index.js @@ -78,6 +78,6 @@ function plugin (fastifyInstance, opts = {}, done) { } module.exports = fp(plugin, { - fastify: '>=3.23.1', + fastify: '>=3.24.1', name: 'fastify-split-validator' })