diff --git a/README.md b/README.md index bcbaae0..68f8cc1 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,47 @@ const myValidator = new Validator({ The `Validator` class contains the following methods. -### `validateDocument({document, schema})` +### `validateAccessMatrix(matrix, fieldName)` + +Validates an access matrix. An optional `fieldName` can be supplied, which will be used in the error objects as the path of the field being validated. + +```js +// Rejected Promise: +// > [ +// > {"field": "foo.invalidType", "code": "ERROR_INVALID_ACCESS_TYPE"}, +// > {"field": "foo.update.invalidKey", "code": "ERROR_INVALID_ACCESS_VALUE"} +// > ] +myValidator + .validateAccessMatrix( + { + create: true, + invalidType: true, + update: { + invalidKey: true + } + }, + 'foo' + ) + .catch(console.log) + +// Resolved Promise: +// > undefined +myValidator.validateAccessMatrix({ + create: true, + delete: { + filter: { + someField: 'someValue' + } + } +}) +``` + +### `validateDocument({document, isUpdate, schema})` Validates a document against a collection schema. It returns a Promise that is resolved with `undefined` if no validation errors occur, or rejected with an array of errors if validation fails. +If `isUpdate` is set to `true`, the method does not throw a validation error if a required field is missing from the candidate document, because it is inferred that a partial update, as opposed to a full document, is being evaluated. + ```js const mySchema = { title: { @@ -51,7 +88,7 @@ const mySchema = { } // Rejected Promise: -// > [{"field": "title", "code": "ERROR_MIN_LENGTH", "message": "is too short"}] +// > [{"field": "title", "code": "ERROR_MIN_LENGTH"}] myValidator .validateDocument({ document: { @@ -75,6 +112,86 @@ myValidator.validateDocument({ Same as `validateDocument` but expects an array of documents (as the `documents` property) and performs validation on each one of them, aborting the process once one of the documents fails validation. +### `validateSchemaField(name, schema)` + +Validates a field schema, evaluating whether `name` is a valid field name and whether `schema` is a valid schema object. + +```js +// Rejected Promise: +// > [{"field": "fields.title", "code": "ERROR_MISSING_FIELD_TYPE"}] +myValidator + .validateSchemaField('title', { + validation: { + minLength: 10 + } + }) + .catch(console.log) + +// Resolved Promise: +// > undefined +myValidator.validateDocument({ + type: 'string', + validation: { + minLength: 10 + } +}) +``` + +### `validateSchemaFieldName(name)` + +Validates a field name. + +```js +// Rejected Promise: +// > [{"field": "fields.$no-good$", "code": "ERROR_INVALID_FIELD_NAME"}] +myValidator.validateSchemaFieldName('$no-good$').catch(console.log) + +// Resolved Promise: +// > undefined +myValidator.validateSchemaFieldName('all-good') +``` + +### `validateSchemaFields(fields)` + +Validates an entire `fields` object from a candidate collection schema. It runs `validateSchemaField` on the individual fields. + +```js +// Rejected Promise: +// > [{"field": "fields", "code": "ERROR_EMPTY_FIELDS"}] +myValidator.validateSchemaFields({}).catch(console.log) + +// Resolved Promise: +// > undefined +myValidator.validateSchemaFieldName({ + title: { + type: 'string', + validation: { + minLength: 10 + } + } +}) +``` + +### `validateSchemaSettings(settings)` + +Validates an entire `settings` object from a candidate collection schema. + +```js +// Rejected Promise: +// > [{"field": "fields.displayName", "code": "ERROR_INVALID_SETTING"}] +myValidator + .validateSchemaSettings({ + displayName: ['should', 'be', 'a', 'string'] + }) + .catch(console.log) + +// Resolved Promise: +// > undefined +myValidator.validateSchemaSettings({ + displayName: 'I am a string' +}) +``` + ### `validateValue({schema, value})` Validates a candidate value against a field schema. It returns a Promise that is resolved with `undefined` if no validation errors occur, or rejected with an error object if validation fails. @@ -90,7 +207,7 @@ const mySchema = { } // Rejected Promise: -// > {"field": "title", "code": "ERROR_MIN_LENGTH", "message": "is too short"} +// > {"field": "title", "code": "ERROR_MIN_LENGTH"} myValidator .validateField({ schema: mySchema, @@ -106,6 +223,8 @@ myValidator.validateDocument({ }) ``` +When a `validationCallback` property is found in `schema`, it is used as a callback function that allows the user to supply additional validation logic that will be executed after the normal validation runs. This function should return a Promise that rejects when validation fails, passing down an error object with an optional `code` property that indicates the error code. + ## License DADI is a data centric development and delivery stack, built specifically in support of the principles of API first and COPE. diff --git a/index.js b/index.js index 4bbe606..90ea23a 100644 --- a/index.js +++ b/index.js @@ -12,12 +12,113 @@ const types = { } const ValidationError = require('./lib/validation-error') +const ACCESS_TYPES = [ + 'delete', + 'deleteOwn', + 'create', + 'read', + 'readOwn', + 'update', + 'updateOwn' +] + class Validator { constructor({i18nFieldCharacter = ':', internalFieldPrefix = '_'} = {}) { this.i18nFieldCharacter = i18nFieldCharacter this.internalFieldPrefix = internalFieldPrefix } + validateAccessMatrix(matrix, fieldName) { + const fieldPrefix = fieldName ? `${fieldName}.` : '' + const errors = [] + + if (typeof matrix === 'object') { + Object.keys(matrix).forEach(type => { + if (!ACCESS_TYPES.includes(type)) { + errors.push({ + code: 'ERROR_INVALID_ACCESS_TYPE', + field: `${fieldPrefix}${type}`, + message: 'is not a valid access type' + }) + } + + switch (typeof matrix[type]) { + case 'boolean': + return + + case 'object': + Object.keys(matrix[type]).forEach(key => { + if (['fields', 'filter'].includes(key)) { + if (typeof matrix[type][key] !== 'object') { + errors.push({ + code: 'ERROR_INVALID_ACCESS_VALUE', + field: `${fieldPrefix}${type}.${key}`, + message: + 'is not a valid value for the access type (expected Object)' + }) + } else if (key === 'fields') { + const fieldsObj = matrix[type][key] + const fields = Object.keys(fieldsObj) + + // A valid fields projection is an object where all fields are + // 0 or 1, never combining the two. + const invalidProjection = fields.some((field, index) => { + if (fieldsObj[field] !== 0 && fieldsObj[field] !== 1) { + return true + } + + const nextField = fields[index + 1] + + if ( + nextField !== undefined && + fieldsObj[field] !== fieldsObj[nextField] + ) { + return true + } + + return false + }) + + if (invalidProjection) { + errors.push({ + code: 'ERROR_INVALID_ACCESS_VALUE', + field: `${fieldPrefix}${type}.fields`, + message: + 'is not a valid field projection – accepted values for keys are either 0 or 1 and they cannot be combined in the same projection' + }) + } + } + } else { + errors.push({ + code: 'ERROR_INVALID_ACCESS_VALUE', + field: `${fieldPrefix}${type}.${key}`, + message: 'is not a valid key for an access value' + }) + } + }) + + break + + default: + errors.push({ + code: 'ERROR_INVALID_ACCESS_VALUE', + field: `${fieldPrefix}${type}`, + message: + 'is not a valid access value (expected Boolean or Object)' + }) + } + }) + } else { + errors.push({ + code: 'ERROR_INVALID_ACCESS_MATRIX', + field: fieldName, + message: 'is not a valid access matrix object' + }) + } + + return errors.length > 0 ? Promise.reject(errors) : Promise.resolve() + } + validateDocument({document = {}, isUpdate = false, schema = {}}) { const errors = [] let chain = Promise.resolve() @@ -45,17 +146,23 @@ class Validator { return this.validateValue({ schema: fieldSchema, value - }).catch(error => { - const errorData = { - field, - message: error.message - } + }).catch(valueError => { + const valueErrors = Array.isArray(valueError) + ? valueError + : [valueError] + + valueErrors.forEach(error => { + const errorData = { + field: error.field || field, + message: error.message + } - if (typeof error.code === 'string') { - errorData.code = error.code - } + if (typeof error.code === 'string') { + errorData.code = error.code + } - errors.push(errorData) + errors.push(errorData) + }) }) }) }) @@ -383,6 +490,10 @@ class Validator { return typeHandler({ schema, value + }).then(() => { + if (typeof schema.validationCallback === 'function') { + return Promise.resolve(schema.validationCallback(value)) + } }) } } diff --git a/test/validate-access-matrix.js b/test/validate-access-matrix.js new file mode 100644 index 0000000..723c934 --- /dev/null +++ b/test/validate-access-matrix.js @@ -0,0 +1,158 @@ +const should = require('should') +const Validator = require('./../index') + +describe('validateAccessMatrix', () => { + it('should reject when the value is not an object', done => { + const validator = new Validator() + + validator.validateAccessMatrix('not-an-object').catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_MATRIX') + error.message.should.be.instanceof(String) + should.not.exist(error.field) + + validator + .validateAccessMatrix('not-an-object', 'someField') + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_MATRIX') + error.message.should.be.instanceof(String) + error.field.should.eql('someField') + + done() + }) + }) + }) + + it('should reject when the matrix contains an invalid access type', done => { + const validator = new Validator() + + validator + .validateAccessMatrix({ + create: true, + notAThing: true + }) + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_TYPE') + error.field.should.eql('notAThing') + error.message.should.be.instanceof(String) + + done() + }) + }) + + it('should reject when the matrix contains an invalid access value', done => { + const validator = new Validator() + + validator + .validateAccessMatrix({ + create: 1234 + }) + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_VALUE') + error.field.should.eql('create') + error.message.should.be.instanceof(String) + + done() + }) + }) + + it('should reject when one of the access values is an object with an invalid key', done => { + const validator = new Validator() + + validator + .validateAccessMatrix({ + create: { + filter: { + someField: 1 + } + }, + update: { + notAThing: { + someField: 0 + } + } + }) + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_VALUE') + error.field.should.eql('update.notAThing') + error.message.should.be.instanceof(String) + + done() + }) + }) + + it('should reject when one of the access values is an object with an invalid value', done => { + const validator = new Validator() + + validator + .validateAccessMatrix({ + create: { + filter: 123 + } + }) + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_VALUE') + error.field.should.eql('create.filter') + error.message.should.be.instanceof(String) + + done() + }) + }) + + it('should reject when one of the access values is an object with an invalid field projection', done => { + const validator = new Validator() + + validator + .validateAccessMatrix({ + create: { + fields: { + field1: 1, + field2: 0 + } + } + }) + .catch(errors => { + const [error] = errors + + error.code.should.eql('ERROR_INVALID_ACCESS_VALUE') + error.field.should.eql('create.fields') + error.message.should.be.instanceof(String) + + done() + }) + }) + + it('should resolve when the value is a valid access matrix', () => { + const validator = new Validator() + + return validator.validateAccessMatrix({ + create: { + fields: { + field1: 1, + field2: 1 + } + }, + deleteOwn: true, + delete: false, + update: { + filter: { + someField: { + $ne: 'someValue' + } + } + }, + read: false + }) + }) +})