diff --git a/docs/Validators.md b/docs/Validators.md index 84f380d..4bb216a 100644 --- a/docs/Validators.md +++ b/docs/Validators.md @@ -17,6 +17,7 @@ Roc offers validators that can be used to make sure values have the correct valu * [isPath](#ispath) * [isPromise](#ispromise) * [isRegExp](#isregexp) + * [isShape](#isshape) * [isString](#isstring) * [notEmpty](#notempty) * [oneOf](#oneof) @@ -115,6 +116,11 @@ import { isArray, isBoolean } from 'roc/validators'; isArray(isBoolean) => validator ``` +__Documentation__ +Will use a syntax in the documentation generation when describing wrapped validators. `[TYPE]` means that the type is optional, `` means that it is required. A question mark in front of the type, `?TYPE`, means that it can be empty. + +`Array()` means that `null` and `undefined` are allowed as values but not empty strings as an example. + ### `isBoolean` ```javascript import { isBoolean } from 'roc/validators'; @@ -167,6 +173,11 @@ import { isObject, isBoolean } from 'roc/validators'; isObject(isBoolean, { unmanaged: false }) => validator ``` +__Documentation__ +Will use a syntax in the documentation generation when describing wrapped validators. `[TYPE]` means that the type is optional, `` means that it is required. A question mark in front of the type, `?TYPE`, means that it can be empty. + +`Object()` means that null and undefined are allowed as values but not empty strings as an example. + ### `isPath` ```javascript import { isPath } from 'roc/validators'; @@ -191,6 +202,39 @@ Will validate the input to make sure it’s a regular expression. `null` and `undefined` are valid. +### `isShape` +```javascript +import { isShape } from 'roc/validators'; + +isShape(validator, options) => validator +``` +Will validate the input to make sure it’s an object matching the defined shape. Possible to provide an optional options object. + +`null` and `undefined` are valid. + +__`options`__ +``` +strict Defaults to true, set to false to allow non-validated properties. +``` + +__Example__ +```javascript +import { isShape, isBoolean } from 'roc/validators'; + +// { a: true } : valid +// { a: true, b: 1 } : not valid +isShape({ a: isBoolean }) => validator + +// { a: true } : valid +// { a: true, b: 1 } : valid +isShape({ a: isBoolean }, { strict: false }) => validator +``` + +__Documentation__ +Will use a syntax in the documentation generation when describing wrapped validators. `[TYPE]` means that the type is optional, `` means that it is required. A question mark infront of the type, `?TYPE`, means that it can be empty. + +`{ a: }` means that `null` and `undefined` are allowed on a, but not empty strings as an example. + ### `isString` ```javascript import { isString } from 'roc/validators'; @@ -224,6 +268,11 @@ import { oneOf, isString, isArray } from 'roc/validators'; oneOf(isString, isArray(isString)) => validator ``` +__Documentation__ +Will use a syntax in the documentation generation when describing wrapped validators. `[TYPE]` means that the type is optional, `` means that it is required. A question mark in front of the type, `?TYPE`, means that it can be empty. + +` / ` means that the value can either be a empty string or a boolean as an example. + ### `required` ```javascript import { required } from 'roc/validators'; diff --git a/src/hooks/runHookDirectly.js b/src/hooks/runHookDirectly.js index b8b5bbf..851077e 100644 --- a/src/hooks/runHookDirectly.js +++ b/src/hooks/runHookDirectly.js @@ -1,4 +1,5 @@ import { magenta, underline } from 'chalk'; +import { isPlainObject } from 'lodash'; import log from '../log/default'; import isValid from '../validation/helpers/isValid'; @@ -97,8 +98,7 @@ export default function runHookDirectly({ try { throwValidationError( `action in ${actionExtensionName} for ${name}`, - validationResult, - previousValue, + ...manageResult(validationResult, previousValue), 'return value of' ); } catch (err) { @@ -144,3 +144,17 @@ export default function runHookDirectly({ return previousValue; } + +function manageResult(validationResult, previousValue) { + if (isPlainObject(validationResult)) { + return [ + `${validationResult.message}${validationResult.key ? ` [In property ${validationResult.key}]` : ''}`, + validationResult.value || previousValue, + ]; + } + + return [ + validationResult, + previousValue, + ]; +} diff --git a/src/validation/helpers/createInfoObject.js b/src/validation/helpers/createInfoObject.js index 34811bc..47ba123 100644 --- a/src/validation/helpers/createInfoObject.js +++ b/src/validation/helpers/createInfoObject.js @@ -9,7 +9,7 @@ export default function createInfoObject({ unmanagedObject = false, } = {}) { const info = isFunction(validator) ? validator(null, true) : { type: validator.toString(), canBeEmpty: null }; - const type = wrapper ? wrapper(info.type) : info.type; + const type = wrapper ? wrapper(info.type, info.canBeEmpty, info.required || false) : info.type; const convert = converter ? converter(info.converter) : info.converter; return { type, diff --git a/src/validation/helpers/writeInfoInline.js b/src/validation/helpers/writeInfoInline.js new file mode 100644 index 0000000..c06e272 --- /dev/null +++ b/src/validation/helpers/writeInfoInline.js @@ -0,0 +1,7 @@ +/** + * Writes information about if a type is required and if it can be empty inline + */ +export default function writeInfoInline(type, canBeEmpty, required) { + const empty = canBeEmpty ? '?' : ''; + return type && `${required ? `<${empty}${type}>` : `[${empty}${type}]`}`; +} diff --git a/src/validation/validateSettings.js b/src/validation/validateSettings.js index ca6d5b6..cb2ff6d 100644 --- a/src/validation/validateSettings.js +++ b/src/validation/validateSettings.js @@ -39,7 +39,7 @@ function assertValid(value, key, validator, allowRequiredFailure = false) { function processResult(key, result, value) { if (isPlainObject(result)) { return [ - `${key}${result.key}`, + `${key}.${result.key}`, result.message, result.value, ]; diff --git a/src/validation/validators/index.js b/src/validation/validators/index.js index f8f3308..7113432 100644 --- a/src/validation/validators/index.js +++ b/src/validation/validators/index.js @@ -6,6 +6,7 @@ export isObject from './isObject'; export isPath from './isPath'; export isPromise from './isPromise'; export isRegExp from './isRegExp'; +export isShape from './isShape'; export isString from './isString'; export notEmpty from './notEmpty'; export oneOf from './oneOf'; diff --git a/src/validation/validators/isArray.js b/src/validation/validators/isArray.js index 95a7e14..11a5e7d 100644 --- a/src/validation/validators/isArray.js +++ b/src/validation/validators/isArray.js @@ -3,6 +3,7 @@ import { isArray as isArrayLodash, isPlainObject } from 'lodash'; import createInfoObject from '../helpers/createInfoObject'; import isValid from '../helpers/isValid'; import toArray from '../../converters/toArray'; +import writeInfoInline from '../helpers/writeInfoInline'; /** * Validates an array using a validator. @@ -16,7 +17,7 @@ export default function isArray(validator) { return createInfoObject({ validator, converter: (converter) => toArray(converter), - wrapper: (wrap) => `Array(${wrap})`, + wrapper: (...args) => `Array(${writeInfoInline(...args)})`, canBeEmpty: true, }); } diff --git a/src/validation/validators/isObject.js b/src/validation/validators/isObject.js index 252e40d..421a87c 100644 --- a/src/validation/validators/isObject.js +++ b/src/validation/validators/isObject.js @@ -3,6 +3,7 @@ import { isPlainObject } from 'lodash'; import createInfoObject from '../helpers/createInfoObject'; import isValid from '../helpers/isValid'; import toObject from '../../converters/toObject'; +import writeInfoInline from '../helpers/writeInfoInline'; /** * Validates an object using a validator. @@ -20,7 +21,7 @@ export default function isObject(...args) { return createInfoObject({ validator, converter: () => toObject, - wrapper: (wrap) => `Object(${wrap})`, + wrapper: (...wrapperArgs) => `Object(${writeInfoInline(...wrapperArgs)})`, canBeEmpty: true, unmanagedObject: unmanaged, }); @@ -43,13 +44,13 @@ export default function isObject(...args) { if (result !== true) { if (isPlainObject(result)) { return { - key: `.${key}${result.key}`, + key: `${key}.${result.key}`, value: result.value, message: result.message, }; } return { - key: `.${key}`, + key: `${key}`, value: input[key], message: result, }; diff --git a/src/validation/validators/isShape.js b/src/validation/validators/isShape.js new file mode 100644 index 0000000..48a0349 --- /dev/null +++ b/src/validation/validators/isShape.js @@ -0,0 +1,74 @@ +import { isPlainObject, difference } from 'lodash'; + +import createInfoObject from '../helpers/createInfoObject'; +import getSuggestions from '../../helpers/getSuggestions'; +import isValid from '../helpers/isValid'; +import toObject from '../../converters/toObject'; +import writeInfoInline from '../helpers/writeInfoInline'; + +export default function isShape(shape, { strict = true } = {}) { + if (!shape || !isPlainObject(shape) || Object.keys(shape).length === 0) { + throw new Error('The isShape validator requires that a shape object is defined.'); + } + + return (input, info) => { + const keys = Object.keys(shape); + + if (info) { + const types = Object.keys(shape).map((key) => createInfoObject({ + validator: shape[key], + wrapper: (...args) => `${key}: ${writeInfoInline(...args)}`, + }).type).join(', '); + + // We do not need a speical converter, since there will never be the case that we + // have a input that is not on the ordinary object form, meaning that we will accept + // whatever we get back from it. + // In addtion to this we should not use this validator for something we get on the + // command line since we have better ways to manage shape like object there. + return createInfoObject({ + validator: types, + converter: () => toObject, + wrapper: (wrap) => `{ ${wrap} }`, + canBeEmpty: true, + }); + } + + if (input === undefined || input === null) { + return true; + } + + if (!isPlainObject(input)) { + return 'Was not an object and can therefore not have a shape!'; + } + + + for (const key of keys) { + const result = isValid(input[key], shape[key]); + + if (result !== true) { + if (isPlainObject(result)) { + return { + key: `${key}.${result.key}`, + value: result.value, + message: result.message, + }; + } + return { + key: `${key}`, + value: input[key], + message: result, + }; + } + } + + if (strict) { + const diff = difference(Object.keys(input), keys); + + if (diff.length > 0) { + return `Unknown propertys where found, make sure this is correct.\n${getSuggestions(diff, keys)}`; + } + } + + return true; + }; +} diff --git a/src/validation/validators/oneOf.js b/src/validation/validators/oneOf.js index 5368ede..83ce27a 100644 --- a/src/validation/validators/oneOf.js +++ b/src/validation/validators/oneOf.js @@ -2,6 +2,7 @@ import convert from '../../converters/convert'; import createInfoObject from '../helpers/createInfoObject'; import getInfoObject from '../helpers/getInfoObject'; import isValid from '../helpers/isValid'; +import writeInfoInline from '../helpers/writeInfoInline'; /** * Validates against a list of validators. @@ -19,7 +20,10 @@ export default function oneOf(...validators) { const types = []; const converters = []; for (const validator of validators) { - const result = createInfoObject({ validator }); + const result = createInfoObject({ + validator, + wrapper: writeInfoInline, + }); types.push(result.type); if (result.converter) { converters.push(result.converter); diff --git a/test/validation/validators/isArray.js b/test/validation/validators/isArray.js index d285518..1cebf72 100644 --- a/test/validation/validators/isArray.js +++ b/test/validation/validators/isArray.js @@ -18,7 +18,7 @@ describe('validators', () => { expect(isArray(validator)(null, true)) .toEqual({ - type: 'Array(Type)', + type: 'Array([Type])', required: false, canBeEmpty: true, converter: toArray(), diff --git a/test/validation/validators/isObject.js b/test/validation/validators/isObject.js index 81863d3..7c5775b 100644 --- a/test/validation/validators/isObject.js +++ b/test/validation/validators/isObject.js @@ -22,7 +22,7 @@ describe('validators', () => { expect(isObject(validator)(null, true)) .toEqual({ - type: 'Object(Type)', + type: 'Object([?Type])', required: false, canBeEmpty: true, converter: toObject, diff --git a/test/validation/validators/isShape.js b/test/validation/validators/isShape.js new file mode 100644 index 0000000..ab558d6 --- /dev/null +++ b/test/validation/validators/isShape.js @@ -0,0 +1,110 @@ +import expect from 'expect'; +import stripAnsi from 'strip-ansi'; + +import isArray from '../../../src/validation/validators/isArray'; +import isObject from '../../../src/validation/validators/isObject'; +import isPath from '../../../src/validation/validators/isPath'; +import isShape from '../../../src/validation/validators/isShape'; +import notEmpty from '../../../src/validation/validators/notEmpty'; +import required from '../../../src/validation/validators/required'; +import toObject from '../../../src/converters/toObject'; + +describe('validators', () => { + describe('isShape', () => { + it('should return throw if no shape is provided', () => { + expect(() => isShape()) + .toThrow('isShape validator requires that a shape object is defined.'); + + expect(() => isShape({})) + .toThrow('isShape validator requires that a shape object is defined.'); + }); + + it('should validate null and undefined as valid', () => { + expect(isShape({ a: /a/ })(null)) + .toBe(true); + + expect(isShape({ a: /a/ })(undefined)) + .toBe(true); + }); + + it('should return info object if requested', () => { + const validator = () => ({ + type: 'Type', + required: false, + canBeEmpty: true, + }); + + expect(isShape({ + a: validator, + b: required(notEmpty(isPath)), + c: isArray(notEmpty(isPath)), + d: isShape({ + e: isPath, + }), + })(null, true)) + .toEqual({ + type: '{ a: [?Type], b: , c: [?Array([Filepath])], d: [?{ e: [?Filepath] }] }', + required: false, + canBeEmpty: true, + converter: toObject, + unmanagedObject: false, + }); + }); + + it('should return error if value is not a plain object', () => { + expect(isShape({ a: /a/ })([])) + .toInclude('Was not an object and can therefore not have a shape!'); + }); + + it('should validate a missing key', () => { + expect(isShape({ a: required(() => true) })({ a: undefined })) + .toEqual({ key: 'a', message: 'A value was required but none was given!', value: undefined }); + }); + + it('should error on undefined keys', () => { + expect(stripAnsi(isShape({ a: () => true })({ b: undefined, c: undefined }))) + .toEqual( + 'Unknown propertys where found, make sure this is correct.\n' + + 'Did not understand b - Did you mean a\n' + + 'Did not understand c - Did you mean a' + ); + }); + + it('should not error on undefined keys when strict is false', () => { + expect(isShape({ a: () => true }, { strict: false })({ b: undefined })) + .toBe(true); + }); + + it('should validate complex shape as valid', () => { + expect(isShape({ + a: isShape({ + b: isArray(isPath), + }), + c: isObject(isPath), + })({ + a: { + b: ['/some/path'], + }, + c: { + d: '', + }, + })).toBe(true); + }); + + it('should validate complex shape as invalid', () => { + expect(isShape({ + a: isShape({ + b: isArray(isPath), + }), + c: isObject(isPath), + })({ + a: { + b: true, + }, + c: { + d: '', + }, + })).toEqual({ key: 'a.b', message: 'Was not an array!', value: true }); + }); + }); +}); diff --git a/test/validation/validators/oneOf.js b/test/validation/validators/oneOf.js index 9db3905..dad9c4e 100644 --- a/test/validation/validators/oneOf.js +++ b/test/validation/validators/oneOf.js @@ -13,14 +13,15 @@ describe('validators', () => { }); it('should return info object if requested', () => { - const validator = () => ({ + const validator = (required = false, canBeEmpty = true) => () => ({ type: 'Type', - required: false, + required, + canBeEmpty, }); - expect(oneOf(validator, validator, validator)(null, true)) + expect(oneOf(validator(), validator(true), validator(true, false))(null, true)) .toEqual({ - type: 'Type / Type / Type', + type: '[?Type] / / ', required: false, canBeEmpty: null, converter: undefined,