From 96c2eefbb5b5648de726d5bcd1e8252ee099d060 Mon Sep 17 00:00:00 2001 From: Angelin Sirbu Date: Sun, 5 Dec 2021 13:22:15 +0200 Subject: [PATCH 1/3] Added conditional validation --- README.md | 46 +++++++++++++++++++++++++++-- src/schema-validator.js | 26 ++++++++++------ tests/schema-validator.test.js | 14 ++++++++- tests/schemas/conditional-schema.js | 39 ++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 tests/schemas/conditional-schema.js diff --git a/README.md b/README.md index 770d209..622e809 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ This library allows you to use any validation library, even your own. Examples a 7. [Using field values in messages](#using-field-values-in-messages) 8. [Async/await validation](#asyncawait-validation) 9. [Cross field validation](#cross-field-validation) -10. [Using sanitizers](#using-sanitizers) -11. [Contributing](#contributing) +10. [Conditional validation](#conditional-validation) +11. [Using sanitizers](#using-sanitizers) +12. [Contributing](#contributing) ## Installation > npm i nodejs-schema-validator @@ -224,6 +225,42 @@ const schema = { }; ``` +## Conditional validation +Some fields can have validation skipped based on conditions. Rules can have a `when` condition that skips the rule in case it returns `false`. + +```js +const schema = { + type: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Type is required' + } + ] + }, + resolution: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Resolution is required', + // Only validate this rule when type is 'monitor' + when: ({ type }) => type === 'monitor' + } + ] + }, + watts: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Power in watts is required', + // Only validate this tule when type is 'speaker' + when: ({ type }) => type === 'speaker' + } + ] + } +} +``` + ## Using sanitizers You can add an array of sanitizers that will be processed after validation: ```js @@ -256,7 +293,10 @@ Sanitized output: { name: 'ksum nole' } ``` ## Contributing -Allowing pull requests. +Pull requests are welcome. Run tests with: +```console +npm run test +``` ## License (MIT) diff --git a/src/schema-validator.js b/src/schema-validator.js index a5aa8f8..567b691 100644 --- a/src/schema-validator.js +++ b/src/schema-validator.js @@ -44,14 +44,23 @@ const processSchema = async (schema, dataSource) => { throw new Error('Rules need to be functions.'); } - const ruleOutcome = await rule.rule(inputValue, dataSource); - - if (!foundError && ruleOutcome === true) { - foundError = true; - errors.push({ - field: key, - message: injectVarsInMessage(dataSource, rule.message) - }); + const skipRuleValidation = rule.hasOwnProperty('when') && typeof rule.when === 'function' && rule.when(dataSource) === false; + + if (!skipRuleValidation) { + const ruleOutcome = await rule.rule(inputValue, dataSource); + + if (!foundError && ruleOutcome === true) { + foundError = true; + + if (!rule.hasOwnProperty('message')) { + throw new Error('All rules must have a message'); + } + + errors.push({ + field: key, + message: injectVarsInMessage(dataSource, rule.message) + }); + } } } } @@ -62,7 +71,6 @@ const processSchema = async (schema, dataSource) => { throw new Error('Sanitizers need to be functions.'); } - console.log(dataSource[key], sanitizerFunction) dataSource[key] = sanitizerFunction(dataSource[key]); } } diff --git a/tests/schema-validator.test.js b/tests/schema-validator.test.js index cebc088..a2cd25e 100644 --- a/tests/schema-validator.test.js +++ b/tests/schema-validator.test.js @@ -5,6 +5,7 @@ const requiredOptionalSchema = require('./schemas/required-optional-schema'); const emailSchema = require('./schemas/email-schema'); const minMaxSchema = require('./schemas/min-max-schema'); const sanitizedSchema = require('./schemas/sanitized-schema'); +const conditionalSchema = require('./schemas/conditional-schema'); const mocks = { req: {}, @@ -94,8 +95,19 @@ describe('Schema validator middleware', () => { it('Should sanitize input', () => { body = { name: 'ELON MUSK' }; - return schemaValidator(mocks.res, mocks.res, mocks.next, sanitizedSchema, body, failFunction).then((validationErrors) => { + return schemaValidator(mocks.req, mocks.res, mocks.next, sanitizedSchema, body, failFunction).then((validationErrors) => { assert.equal(body.name, 'ksum nole'); }); }); + + it('Should take conditional rules into account', () => { + body = { + type: 'monitor', + resolution: '1080p' + } + + return schemaValidator(mocks.req, mocks.res, mocks.next, conditionalSchema, body, failFunction).then((validationErrors) => { + assert.equal(typeof validationErrors, 'undefined'); + }) + }); }); diff --git a/tests/schemas/conditional-schema.js b/tests/schemas/conditional-schema.js new file mode 100644 index 0000000..9335dda --- /dev/null +++ b/tests/schemas/conditional-schema.js @@ -0,0 +1,39 @@ +const validator = require('validator'); + +const productTypes = { + MONITOR: 'monitor', + SPEAKER: 'speaker' +} + +module.exports = { + type: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Type is required' + }, + { + rule: (input) => !validator.isIn(input, [productTypes.MONITOR, productTypes.SPEAKER]), + message: 'Type should be one of values ' + [productTypes.MONITOR, productTypes.SPEAKER].join(', ') + } + ] + }, + resolution: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Resolution is required', + when: ({ type }) => type === productTypes.MONITOR + } + ] + }, + watts: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'Watts is required', + when: ({ type }) => type === productTypes.SPEAKER + } + ] + } +}; From 77583824a4ea80e6fe36277ad7e8aca504df300c Mon Sep 17 00:00:00 2001 From: Angelin Sirbu Date: Sun, 5 Dec 2021 21:40:35 +0200 Subject: [PATCH 2/3] Added conditional validation, improved code, tests and documentation --- README.md | 59 +++++---- index.js | 64 ---------- src/schema-validator.js | 212 ++++++++++++++++++++------------- tests/schema-validator.test.js | 30 +++-- 4 files changed, 184 insertions(+), 181 deletions(-) delete mode 100644 index.js diff --git a/README.md b/README.md index 622e809..4592a98 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ This library allows you to use any validation library, even your own. Examples a # Table of contents 1. [Installation](#installation) -2. [Quick example](#quick-example) +2. [Basic usage](#basic-usage) 3. [Schema structure](#schema-structure) 4. [Optional fields](#optional-fields) 5. [Validating both body and parameters](#validating-both-body-and-parameters) -6. [Validation output](#validation-output) +6. [Custom validation output](#custom-validation-output) 7. [Using field values in messages](#using-field-values-in-messages) 8. [Async/await validation](#asyncawait-validation) 9. [Cross field validation](#cross-field-validation) @@ -25,12 +25,19 @@ This library allows you to use any validation library, even your own. Examples a ## Installation > npm i nodejs-schema-validator -## Quick example +## Basic usage This validates a post request by looking into `req.body` for email value. ```js -const { bodySchemaValidator } = require('nodejs-schema-validator'); +// Import SchemaValidator +const SchemaValidator = require('nodejs-schema-validator'); + +// Import a validator library of your choice const validator = require('validator'); +// Create a new schema validator instance +const schemaValidatorInstance = new SchemaValidator(); + +// Define your schema (or import it from a dedicated file) const userEmailSchema = { email: { rules: [ @@ -49,8 +56,8 @@ const userEmailSchema = { // Add body schema validator as a middleware to your router endpoints router.post( '/user/:id', - bodySchemaValidator(userEmailSchema), - (req, res) => { /* Your logic */ } + schemaValidatorInstance.validateBody(userEmailSchema), + (req, res) => { /* Data is valid, add your logic */ } ); ``` @@ -82,7 +89,7 @@ const schemaExample = { message: 'This is not a valid Bitcoin address' ] } -} +}; ``` ## Optional fields @@ -97,16 +104,13 @@ const schema = { message: 'Please insert a valid VAT number of leave empty' ] } -} +}; ``` ## Validating both body and parameters Example of how to validate both body and url parameters ```js -const { bodySchemaValidator, paramSchemaValidator } = require('nodejs-schema-validator'); -const validator = require('validator'); - // Schema for validating id as a valid UUID v4 const userParamSchema = { id: { @@ -117,25 +121,36 @@ const userParamSchema = { } ] } -} +}; + +const userBodySchema = { + name: { + rules: [ + { + rule: (input) => !input || input === '', + message: 'User name is mandatory' + } + ] + } +}; // This validates user data router.post( '/user/', - bodySchemaValidator(userBodySchema), + schemaValidatorInstance.validateBody(userBodySchema), (req, res) => { /* Your logic */ } ); // This validates both user id and user data router.put( '/user/:id', - paramSchemaValidator(userParamSchema), - bodySchemaValidator(userBodySchema), + schemaValidatorInstance.validateParams(userParamSchema), + schemaValidatorInstance.validateBody(userBodySchema), (req, res) => { /* Your logic */ } ) ``` -## Validation output +## Custom validation output Validation failure returns status code 422 with a body in this format: ```js { @@ -148,18 +163,16 @@ Validation failure returns status code 422 with a body in this format: } ``` -In case you want to customize the output and status code of the failure you can pass a function as the second parameter to the middleware. It can be passed to both `paramSchemaValidator` and `bodySchemaValidator`. +In case you want to customize the output and status code of the failure you can pass a function in the `SchemaValidator` constructor: ```js +// Define your function in this format const myCustomValidationOutput = (req, res, errors) => { res.status(422).json({ message: errors }); -} +}; -router.post( - '/user/', - bodySchemaValidator(userBodySchema, myCustomValidationOutput), - (req, res) => { /* Your logic */ } -) +// Pass it in constructor +const schemaValidatorInstance = new SchemaValidator(myCustomValidationOutput); ``` ## Using field values in messages diff --git a/index.js b/index.js deleted file mode 100644 index d6b710a..0000000 --- a/index.js +++ /dev/null @@ -1,64 +0,0 @@ -const express = require('express'); -const cors = require('cors'); -const validator = require('validator'); -const emailExists = require('./src/tests/email-exists'); - -const { paramSchemaValidator, bodySchemaValidator } = require('./src/schema-validator'); - -const app = express(); -app.use(express.json()); -app.use(cors({ - origin: '*', - optionsSuccessStatus: 200 -})); - -const port = 4321; - -const paramSchema = { - id: { - rules: [ - { - rule: (input) => !validator.isUUID(input, 4), - message: 'ID should be a valid v4 UUID' - } - ] - } -}; - -const bodySchema = { - name: { - rules: [ - { - rule: (input) => !input || validator.isEmpty(input), - message: 'Name is required' - } - ] - }, - email: { - rules: [ - { - rule: (input) => !input || validator.isEmpty(input), - message: 'Email is required' - }, - { - rule: async (input) => await emailExists(input), - message: 'This email address already exists' - } - ] - } -}; - -const router = new express.Router(); -router.post('/update/test/:id', - paramSchemaValidator(paramSchema), - bodySchemaValidator(bodySchema), - (req, res) => { - res.status(200).json({ message: 'SUCCESS' }); - } -); - -app.use(router); - -app.listen(port, () => { - console.log(`Running in ${process.env.NODE_ENV} environment on port ${port}`); -}); diff --git a/src/schema-validator.js b/src/schema-validator.js index 567b691..62ea938 100644 --- a/src/schema-validator.js +++ b/src/schema-validator.js @@ -3,6 +3,7 @@ * * @param {Object} vars * @param {String} message + * * @returns {String} */ const injectVarsInMessage = (vars, message) => { @@ -16,122 +17,169 @@ const injectVarsInMessage = (vars, message) => { }; /** - * Processes schema rules by passing a data source + * Checks if a schema field has optional validation * * @param {Object} schema * @param {Object} dataSource - * @returns {Promise} + * @param {String} key + * + * @returns {Boolean} */ -const processSchema = async (schema, dataSource) => { - const errors = []; - let foundError = false; - const schemaKeys = Object.keys(schema); - - for (const key of schemaKeys) { - const inputValue = dataSource.hasOwnProperty(key) ? dataSource[key] : ''; - foundError = false; - - const optionalValidation = schema[key].hasOwnProperty('optional') && schema[key].optional && ( - !dataSource.hasOwnProperty(key) || - dataSource[key] === '' || - dataSource[key] === null || - dataSource[key] === undefined - ); - - if (!optionalValidation) { - for (const rule of schema[key].rules) { - if (typeof rule.rule !== 'function') { - throw new Error('Rules need to be functions.'); - } - - const skipRuleValidation = rule.hasOwnProperty('when') && typeof rule.when === 'function' && rule.when(dataSource) === false; - - if (!skipRuleValidation) { - const ruleOutcome = await rule.rule(inputValue, dataSource); - - if (!foundError && ruleOutcome === true) { - foundError = true; - - if (!rule.hasOwnProperty('message')) { - throw new Error('All rules must have a message'); - } - - errors.push({ - field: key, - message: injectVarsInMessage(dataSource, rule.message) - }); - } - } - } - } +const isOptionalValidation = (schema, dataSource, key) => { + return schema[key].hasOwnProperty('optional') && schema[key].optional && ( + !dataSource.hasOwnProperty(key) || + dataSource[key] === '' || + dataSource[key] === null || + dataSource[key] === undefined + ); +}; - if (schema[key].hasOwnProperty('sanitizers')) { - for (const sanitizerFunction of schema[key].sanitizers) { - if (typeof sanitizerFunction !== 'function') { - throw new Error('Sanitizers need to be functions.'); - } +/** + * Checks if rule validation should be skipped + * + * @param {Function} rule + * @param {Object} targetObject + * + * @returns {Boolean} + */ +const shouldSkipRuleValidation = (rule, targetObject) => { + return rule.hasOwnProperty('when') && typeof rule.when === 'function' && rule.when(targetObject) === false; +}; + +/** + * Processes a validation rule + * + * @param {SchemaValidator} context + * @param {Function} rule + * @param {String} field + * @param {String} inputValue + */ +const processRule = async (context, rule, field, inputValue) => { + if (typeof rule.rule !== 'function') { + throw new Error('Rules need to be functions.'); + } - dataSource[key] = sanitizerFunction(dataSource[key]); + if (!shouldSkipRuleValidation(rule, context.targetObject)) { + const ruleOutcome = await rule.rule(inputValue, context.targetObject); + + if (!context.foundError && ruleOutcome === true) { + context.foundError = true; + + if (!rule.hasOwnProperty('message')) { + throw new Error('All rules must have a message'); } + + context.addError(field, injectVarsInMessage(context.targetObject, rule.message)); } } - - return errors; } /** - * Default fail callback function + * Processes schema rules by passing a data source + * + * @param {SchemaValidator} context + * @param {String} fieldName * - * @param {Object} req Request object - * @param {Object} res Response object - * @param {Object[]} errors Array containing the validation errors - * @returns {Function} + * @returns {Promise} */ -const defaultFailCallback = (req, res, errors) => res.status(422).json({ message: errors }); +const processField = async (context, fieldName) => { + const inputValue = context.targetObject.hasOwnProperty(fieldName) ? context.targetObject[fieldName] : ''; + context.foundError = false; + + if (!isOptionalValidation(context.schema, context.targetObject, fieldName)) { + for (const rule of context.schema[fieldName].rules) { + await processRule(context, rule, fieldName, inputValue) + } + } + + if (context.schema[fieldName].hasOwnProperty('sanitizers')) { + for (const sanitizerFunction of context.schema[fieldName].sanitizers) { + if (typeof sanitizerFunction !== 'function') { + throw new Error('Sanitizers need to be functions.'); + } + + context.targetObject[fieldName] = sanitizerFunction(context.targetObject[fieldName]); + } + } +} + +class SchemaValidator { + /** + * @constructor + * @param {Function} failCallback + */ + constructor(failCallback) { + this.failCallback = failCallback || this.defaultFailCallback; + } + + /** + * Default fail callback function + * + * @param {Object} req Request object + * @param {Object} res Response object + * @param {Object[]} errors Array containing the validation errors + * + * @returns {Function} + */ + defaultFailCallback = (req, res, errors) => res.status(422).json({ message: errors }); -module.exports = { /** * Validates a schema based on a targer object * Calls next() on success, failCallback() on failure * - * @param {Object} schema Schema onject - * @param {Object} targetObject Target object (request ot response) - * @param {Function} failCallback Function to be called on failure - * @returns {Function} Middleware function + * @param {Object} req Request object + * @param {Object} res Response object + * @param {Function} next Function to be called on success + * @param {Object} targetObject Object to be validated + * + * @returns {Function} */ - schemaValidator: async (req, res, next, schema, targetObject, failCallback) => { - const errors = await processSchema(schema, targetObject); + async runValidationMiddleware(req, res, next, schema, targetObject) { + this.errors = []; + this.foundError = false; + this.schema = schema; + this.targetObject = targetObject; + + for (const fieldName of Object.keys(this.schema)) { + await processField(this, fieldName); + } - if (errors.length === 0) { + if (this.errors.length === 0) { return next(); } - if (!failCallback) { - failCallback = defaultFailCallback; - } + return this.failCallback(req, res, this.errors); + }; - return failCallback(req, res, errors); - }, + /** + * Adds field error in errors list + * + * @param {String} field + * @param {String} message + */ + addError (field, message) { + this.errors.push({ field, message }); + }; /** * Validates a schema based on req.body * - * @param {Object} schema Validation schema - * @param {Function} failCallback Failure callback function (optional) - * @returns {Function} Middleware function + * @param {Object} schema Validation schema + * @returns {Function} Middleware function */ - bodySchemaValidator: (schema, failCallback) => { - return async (req, res, next) => await module.exports.schemaValidator(req, res, next, schema, req.body, failCallback); - }, + validateBody(schema) { + return async (req, res, next) => await this.runValidationMiddleware(req, res, next, schema, req.body); + }; /** * Validates a schema based on req.params * - * @param {Object} schema Validation schema - * @param {Function} failCallback Failure callback function (optional) - * @returns {Function} Middleware function + * @param {Object} schema Validation schema + * @returns {Function} Middleware function */ - paramSchemaValidator: (schema, failCallback) => { - return async (req, res, next) => await module.exports.schemaValidator(req, res, next, schema, req.params, failCallback); + validateParams(schema) { + return async (req, res, next) => await this.runValidationMiddleware(req, res, next, schema, req.params); } } + +module.exports = SchemaValidator; diff --git a/tests/schema-validator.test.js b/tests/schema-validator.test.js index a2cd25e..187347b 100644 --- a/tests/schema-validator.test.js +++ b/tests/schema-validator.test.js @@ -1,13 +1,21 @@ -const { schemaValidator } = require('../src//schema-validator'); +const SchemaValidator = require('../src/schema-validator'); const assert = require('assert'); +// Test schemas const requiredOptionalSchema = require('./schemas/required-optional-schema'); const emailSchema = require('./schemas/email-schema'); const minMaxSchema = require('./schemas/min-max-schema'); const sanitizedSchema = require('./schemas/sanitized-schema'); const conditionalSchema = require('./schemas/conditional-schema'); -const mocks = { +// Custom fail function +const failFunction = (req, res, errors) => errors; + +// Schema validator instance +const schemaValidator = new SchemaValidator(failFunction); + +// Middleware req, res, next mock +const mock = { req: {}, res: { status: (code) => { @@ -17,8 +25,6 @@ const mocks = { next: () => {} } -const failFunction = (req, res, errors) => errors; - describe('Schema validator middleware', () => { it('Should call next()', () => { let increment = 0; @@ -27,13 +33,13 @@ describe('Schema validator middleware', () => { const schema = {}; const body = {}; - return schemaValidator(mocks.req, mocks.res, nextFunction, schema, body) + return schemaValidator.runValidationMiddleware(mock.req, mock.res, nextFunction, schema, body) .then(() => { assert.equal(increment, 1); }) }); - let validatorResponse = schemaValidator(mocks.req, mocks.res, mocks.next, requiredOptionalSchema, {}, failFunction) + let validatorResponse = schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, requiredOptionalSchema, {}, failFunction) it('Should contain exactly one validation error', () => { return validatorResponse.then((validationErrors) => { @@ -52,7 +58,7 @@ describe('Schema validator middleware', () => { email: 'exists@domain.com' } - return schemaValidator(mocks.req, mocks.res, mocks.next, emailSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, emailSchema, body, failFunction).then((validationErrors) => { assert.equal(validationErrors.length, 1); }); }); @@ -62,7 +68,7 @@ describe('Schema validator middleware', () => { email: 'does-not-exist@domain.com' } - return schemaValidator(mocks.req, mocks.res, mocks.next, emailSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, emailSchema, body, failFunction).then((validationErrors) => { assert.equal(typeof validationErrors, 'undefined'); }); }); @@ -75,7 +81,7 @@ describe('Schema validator middleware', () => { value: 50 } - return schemaValidator(mocks.req, mocks.res, mocks.next, minMaxSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, minMaxSchema, body, failFunction).then((validationErrors) => { assert.equal(typeof validationErrors, 'undefined'); }); }); @@ -87,7 +93,7 @@ describe('Schema validator middleware', () => { value: 150 } - return schemaValidator(mocks.req, mocks.res, mocks.next, minMaxSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, minMaxSchema, body, failFunction).then((validationErrors) => { assert.notEqual(typeof validationErrors, 'undefined'); }); }); @@ -95,7 +101,7 @@ describe('Schema validator middleware', () => { it('Should sanitize input', () => { body = { name: 'ELON MUSK' }; - return schemaValidator(mocks.req, mocks.res, mocks.next, sanitizedSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, sanitizedSchema, body, failFunction).then((validationErrors) => { assert.equal(body.name, 'ksum nole'); }); }); @@ -106,7 +112,7 @@ describe('Schema validator middleware', () => { resolution: '1080p' } - return schemaValidator(mocks.req, mocks.res, mocks.next, conditionalSchema, body, failFunction).then((validationErrors) => { + return schemaValidator.runValidationMiddleware(mock.req, mock.res, mock.next, conditionalSchema, body, failFunction).then((validationErrors) => { assert.equal(typeof validationErrors, 'undefined'); }) }); From 26fb6d0b7daf34fa9d9452b5b2cae38af248b582 Mon Sep 17 00:00:00 2001 From: Angelin Sirbu Date: Sun, 5 Dec 2021 21:41:41 +0200 Subject: [PATCH 3/3] Bumped version to 0.11.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d425fc..567b312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodejs-schema-validator", - "version": "0.10.4", + "version": "0.11.0", "description": "NodeJS validation using schemas", "main": "./src/schema-validator.js", "scripts": {