diff --git a/README.md b/README.md index 770d209..4592a98 100644 --- a/README.md +++ b/README.md @@ -10,26 +10,34 @@ 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) -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 -## 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: [ @@ -48,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 */ } ); ``` @@ -81,7 +89,7 @@ const schemaExample = { message: 'This is not a valid Bitcoin address' ] } -} +}; ``` ## Optional fields @@ -96,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: { @@ -116,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 { @@ -147,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 @@ -224,6 +238,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 +306,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/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/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": { diff --git a/src/schema-validator.js b/src/schema-validator.js index a5aa8f8..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,114 +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 ruleOutcome = await rule.rule(inputValue, dataSource); - - if (!foundError && ruleOutcome === true) { - foundError = true; - 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 + ); +}; + +/** + * 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; +}; - if (schema[key].hasOwnProperty('sanitizers')) { - for (const sanitizerFunction of schema[key].sanitizers) { - if (typeof sanitizerFunction !== 'function') { - throw new Error('Sanitizers need to be functions.'); - } +/** + * 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.'); + } + + if (!shouldSkipRuleValidation(rule, context.targetObject)) { + const ruleOutcome = await rule.rule(inputValue, context.targetObject); + + if (!context.foundError && ruleOutcome === true) { + context.foundError = true; - console.log(dataSource[key], sanitizerFunction) - dataSource[key] = sanitizerFunction(dataSource[key]); + 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 {Object} req Request object - * @param {Object} res Response object - * @param {Object[]} errors Array containing the validation errors - * @returns {Function} + * @param {SchemaValidator} context + * @param {String} fieldName + * + * @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 cebc088..187347b 100644 --- a/tests/schema-validator.test.js +++ b/tests/schema-validator.test.js @@ -1,12 +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) => { @@ -16,8 +25,6 @@ const mocks = { next: () => {} } -const failFunction = (req, res, errors) => errors; - describe('Schema validator middleware', () => { it('Should call next()', () => { let increment = 0; @@ -26,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) => { @@ -51,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); }); }); @@ -61,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'); }); }); @@ -74,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'); }); }); @@ -86,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'); }); }); @@ -94,8 +101,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.runValidationMiddleware(mock.req, mock.res, mock.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.runValidationMiddleware(mock.req, mock.res, mock.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 + } + ] + } +};