diff --git a/README.md b/README.md index c2a8c66..3388594 100755 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ and optionally translated for clarity or internationalization. [![Build Status](https://travis-ci.org/feathers-plus/validate-joi.svg?branch=master)](https://travis-ci.org/feathers-plus/validate-joi) [![Coverage Status](https://coveralls.io/repos/github/feathers-plus/validate-joi/badge.svg?branch=master)](https://coveralls.io/github/feathers-plus/validate-joi?branch=master) +## New in Version 3.2 + +Version 3.2 adds the `validateProvidedData` hook, which can be very useful in validating patch requests. + ## New in Version 3.1 - 🙌 Updated to work with latest `@hapi/joi`. @@ -16,6 +20,8 @@ and optionally translated for clarity or internationalization. Since `Joi.validate()` has been removed, all validations now use `schema.validateAsync()`, which means this package now supports asynchronous validations. +If you're using MongoDB, be sure to take a look at [@feathers-plus/validate-joi-mongodb](https://github.com/feathers-plus/validate-joi-mongodb) for some time-saving utilities. + ## Installation ``` @@ -114,6 +120,44 @@ export.before = { }; ``` +## validateProvidedData Hook + +The `validateProvidedData` hook is just like `validate.form`, but it only validates the attributes from the schema which are actually present in the request's `data` object. In short, it allows partial validation of the schema attributes. Using it as a hook looks like this: + +```js +const validate = require('@featehrs-plus/validate-joi') +const attrs = require('./faqs.model') + +const hooks = { + before: { + patch: [ + validate.validateProvidedData(attrs, { abortEarly: false }) + ] + } +} +``` + +The above example supposes that you have an `/faqs` service with a model that looks like the following. Notice how the `attrs` are defined as a separate object, then they are used in the schema and made available in the export. The `validateProvidedData` hook uses the individual attrs to validate each individual item in the request's `data` object. + +```js +// src/services/faqs/faqs.model.js +const Joi = require('@hapi/joi') +const { objectId } = require('@feathers-plus/validate-joi-mongodb') + +const attrs = { + _id: objectId(), + question: Joi.string().disallow(null).required(), + answer: Joi.string().disallow(null).required(), + isPublic: Joi.boolean().default(false), + createdBy: objectId().disallow(null).required() +} + +module.exports = { + attrs, + schema: Joi.object(attrs) +} +``` + ## Motivation Data must be validated and sanitized before the database is changed. diff --git a/index.js b/index.js index c3fa7e3..9d7e897 100755 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ const errors = require('@feathersjs/errors'); const utils = require('feathers-hooks-common/lib/services'); const joiErrorsForForms = require('joi-errors-for-forms'); +const Joi = require('@hapi/joi'); +const pick = require('lodash/pick'); // We only directly need the convert option. The others are listed for convenience. // See defaults at https://hapi.dev/family/joi/api/?v=17.1.0#anyvalidatevalue-options @@ -67,7 +69,7 @@ function setupValidateWithJoi(joiSchema, joiOptions, translator, ifTest) { }; } -module.exports = { +const validators = { form: function (joiSchema, joiOptions, translations, ifTest) { const translator = joiErrorsForForms.form(translations); return setupValidateWithJoi(joiSchema, joiOptions, translator, ifTest); @@ -77,3 +79,33 @@ module.exports = { return setupValidateWithJoi(joiSchema, joiOptions, translator, ifTest); } }; + +/** + * The validatedProvidedAttrs hook is great for validating patch requests, where a partial + * schema needs to be validated. It only validates the attributes that have matching keys + * in `context.data`. + * @param {Object} validationsObj - an object containing the raw keys from a service's + * schema object. It cannot be already wrapped in `Joi.object(validationsObject)`. + * @param {JoiOptionsObject} joiOptions + */ +function setupValidateProvidedData(validationsObj, joiOptions) { + if (!validationsObj || typeof validationsObj !== 'object') { + throw new Error('The `validationsObj` argument is required.'); + } + return function validatedProvidedData(context) { + if (context.type === 'after') { + throw new Error('validateProvidedData can only be a before hook'); + } + const patchAttrs = pick(validationsObj, Object.keys(context.data)); + const patchSchema = Joi.object(patchAttrs); + + const validateHook = validators.form(patchSchema, joiOptions); + + return validateHook(context); + }; +} + +Object.assign(validators, { validateProvidedData: setupValidateProvidedData }); + + +module.exports = validators; diff --git a/package.json b/package.json index 80737bb..f6613a4 100755 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ ], "author": "John Szwaronek ", "contributors": [ - "Luis A. Salas " + "Luis A. Salas ", + "Marshall Thompson " ], "license": "MIT", "bugs": { @@ -42,7 +43,8 @@ "@feathersjs/errors": "^4.5.1", "@hapi/joi": "^17.1.0", "feathers-hooks-common": "^5.0.2", - "joi-errors-for-forms": "^0.2.2" + "joi-errors-for-forms": "^0.2.2", + "lodash": "^4.17.15" }, "devDependencies": { "babel-eslint": "^10.0.3", @@ -55,7 +57,7 @@ "eslint-plugin-react": "7.18.3", "istanbul": "^0.4.5", "mocha": "^7.0.1", - "mongodb": "^3.5.3" + "mongodb": "^3.5.5" }, "engines": { "node": ">4.2.4" diff --git a/test/valid_spec.js b/test/valid_spec.js index 57a8bdc..16ff66c 100755 --- a/test/valid_spec.js +++ b/test/valid_spec.js @@ -4,6 +4,7 @@ one-var-declaration-per-line: 0, prefer-arrow-callback: 0 */ /* ES5 code */ const { assert } = require('chai'); const Joi = require('@hapi/joi'); +const { ObjectID } = require('mongodb'); const validate = require('../index'); const name = Joi.string().trim().regex(/^[\sa-zA-Z0-9]{5,30}$/).required(); @@ -14,7 +15,6 @@ const schema = Joi.object().keys({ confirmPassword: password.label('Confirm password'), }); -const { ObjectID } = require('mongodb'); /** * Custom objectId validator */ diff --git a/test/validate-provided-data.test.js b/test/validate-provided-data.test.js new file mode 100644 index 0000000..0d49641 --- /dev/null +++ b/test/validate-provided-data.test.js @@ -0,0 +1,76 @@ +const assert = require('assert'); +const Joi = require('@hapi/joi'); +const { validateProvidedData: setupValidate } = require('../index'); + +describe('validate-provided-data hook', () => { + it('throws an error if no valdationAttrs are provided', async () => { + try { + setupValidate(); + } catch (error) { + assert.equal(error.message, 'The `validationsObj` argument is required.'); + } + }); + + it('throws an error if used as an after hook', async () => { + const attrs = { + name: Joi.string().required(), + email: Joi.string().required(), + }; + const context = { + type: 'after', + }; + + try { + const validate = setupValidate(attrs); + const responseContext = await validate(context); + assert(!responseContext, 'should have failed when used as an after hook'); + } catch (error) { + assert.equal(error.message, 'validateProvidedData can only be a before hook', + 'should have been able to validate'); + } + }); + + it('validates only the attributes in context.data', async () => { + const attrs = { + name: Joi.string().required(), + email: Joi.string().required(), + }; + const context = { + type: 'before', + data: { + name: 'Marshall', + }, + }; + + try { + const validate = setupValidate(attrs); + const responseContext = await validate(context); + assert(responseContext); + } catch (error) { + assert(!error, 'should have been able to validate'); + } + }); + + it('can fail if validations do not match', async () => { + const attrs = { + name: Joi.string().required(), + email: Joi.string().required(), + }; + const context = { + type: 'before', + data: { + name: 'Marshall', + email: 200, // this should fail + }, + }; + + try { + const validate = setupValidate(attrs); + const options = { convert: true, abortEarly: false, stripUnknown: true }; + const responseContext = await validate(context, options); + assert(!responseContext, 'should have failed email validation'); + } catch ({ errors }) { + assert.equal(errors.email, '"email" must be a string', 'validation should have failed for bad email'); + } + }); +}); diff --git a/yarn.lock b/yarn.lock index fb09e7f..aa64992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1592,10 +1592,10 @@ mocha@^7.0.1: yargs-parser "13.1.1" yargs-unparser "1.6.0" -mongodb@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.5.3.tgz#f2c7ce9b5fc9a13da116ff1b6e816f6256010a86" - integrity sha512-II7P7A3XUdPiXRgcN96qIoRa1oesM6qLNZkzfPluNZjVkgQk3jnQwOT6/uDk4USRDTTLjNFw2vwfmbRGTA7msg== +mongodb@^3.5.5: + version "3.5.5" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.5.5.tgz#1334c3e5a384469ac7ef0dea69d59acc829a496a" + integrity sha512-GCjDxR3UOltDq00Zcpzql6dQo1sVry60OXJY3TDmFc2SWFY6c8Gn1Ardidc5jDirvJrx2GC3knGOImKphbSL3A== dependencies: bl "^2.2.0" bson "^1.1.1"