├── .eslintignore ├── .gitignore ├── src ├── uncoveredPaths │ ├── index.js │ ├── getAllPaths │ │ ├── index.js │ │ └── index.test.js │ ├── pathDiff │ │ ├── index.js │ │ └── index.test.js │ └── index.test.js ├── index.js ├── validate-reducer │ ├── index.js │ └── index.test.js ├── checkConfig │ ├── reformatResult │ │ ├── index.js │ │ └── index.test.js │ ├── index.js │ └── index.test.js ├── configValidator │ ├── index.js │ └── index.test.js └── formatMessagesAsString │ ├── index.js │ └── index.test.js ├── .travis.yml ├── LICENCE ├── CONTRIBUTING.md ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .nyc_output/ 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | 6 | -------------------------------------------------------------------------------- /src/uncoveredPaths/index.js: -------------------------------------------------------------------------------- 1 | import {uniq} from 'lodash' 2 | import pathDiff from './pathDiff' 3 | import getAllPaths from './getAllPaths' 4 | 5 | export default uncoveredPaths 6 | 7 | function uncoveredPaths(config, validators) { 8 | const allPaths = getAllPaths(config) 9 | const validatedPaths = uniq(validators.map(v => v.key)) 10 | return pathDiff(allPaths, validatedPaths) 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import configValidator from './configValidator' 2 | 3 | import checkConfig from './checkConfig' 4 | import messageFormatter from './messageFormatter' 5 | import uncoveredPaths from './uncoveredPaths' 6 | import validateReducer from './validateReducer' 7 | 8 | module.exports = configValidator 9 | module.exports.checkConfig = checkConfig 10 | module.exports.messageFormatter = messageFormatter 11 | module.exports.uncoveredPaths = uncoveredPaths 12 | module.exports.validateReducer = validateReducer 13 | 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '4' 10 | - '0.10' 11 | before_install: 12 | - npm i -g npm@^3.0.0 13 | before_script: 14 | - npm prune 15 | script: 16 | - npm run validate 17 | after_success: 18 | - 'curl -Lo travis_after_all.py https://git.io/travis_after_all' 19 | - python travis_after_all.py 20 | - 'export $(cat .to_export_back) &> /dev/null' 21 | - npm run report-coverage 22 | branches: 23 | only: 24 | - master 25 | 26 | -------------------------------------------------------------------------------- /src/validate-reducer/index.js: -------------------------------------------------------------------------------- 1 | export {validateReducer} 2 | 3 | const EXIT_EARLY = 'EXIT_EARLY' 4 | const CONTINUE = 'CONTINUE' 5 | 6 | validateReducer.EXIT_EARLY = EXIT_EARLY 7 | validateReducer.CONTINUE = CONTINUE 8 | 9 | function validateReducer(validators, val, config) { 10 | const result = validators.reduce((res, validator) => { 11 | if (res !== CONTINUE) { 12 | return res 13 | } 14 | return validator(val, config) 15 | }, CONTINUE) 16 | 17 | if (result !== CONTINUE && result !== EXIT_EARLY) { 18 | return result 19 | } else { 20 | return undefined 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/checkConfig/reformatResult/index.js: -------------------------------------------------------------------------------- 1 | import {isString, isPlainObject} from 'lodash' 2 | 3 | export default reformatResult 4 | 5 | function reformatResult(result, {description, key} = {}) { 6 | /* eslint complexity:[2, 6] */ 7 | if (isString(result)) { 8 | return {type: 'error', message: result} 9 | } else if (isPlainObject(result)) { 10 | if (result.error) { 11 | return {type: 'error', message: result.error} 12 | } else if (result.warning) { 13 | return {type: 'warning', message: result.warning} 14 | } 15 | } 16 | throw new Error([ 17 | 'config-validator is returning a non-string non-conforming object.', 18 | `Returned: ${JSON.stringify(result)} for ${description || key}`, 19 | ].join(' ')) 20 | } 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/uncoveredPaths/getAllPaths/index.js: -------------------------------------------------------------------------------- 1 | import {isPlainObject, isEmpty, flatten, includes} from 'lodash' 2 | 3 | export default getAllPaths 4 | 5 | function getAllPaths(object, base = '', visited = []) { 6 | if (!isPlainObject(object) || isEmpty(object)) { 7 | return [] 8 | } 9 | const paths = Object.keys(object).map(key => { 10 | const val = object[key] 11 | const alreadyVisited = includes(visited, val) 12 | if (alreadyVisited) { 13 | return key 14 | } 15 | const path = base ? `${base}.${key}` : key 16 | if (!isPlainObject(val)) { 17 | return path 18 | } 19 | 20 | visited.push(val) // only add visited to plain objects 21 | return getAllPaths(object[key], path, visited) 22 | }) 23 | return flatten(paths) 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/uncoveredPaths/pathDiff/index.js: -------------------------------------------------------------------------------- 1 | import {toPath} from 'lodash' 2 | export default pathDiff 3 | 4 | function pathDiff(keysToCover, coverageKeys) { 5 | const pathsToCover = keysToCover.map(key => toPath(key)) 6 | const coveragePaths = coverageKeys.map(key => toPath(key)) 7 | 8 | const uncoveredPaths = pathsToCover.reduce(findUncoveredPathsReducer, []) 9 | return uncoveredPaths 10 | 11 | function findUncoveredPathsReducer(accumulator, currentPath, index) { 12 | if (!pathIsCovered(currentPath)) { 13 | accumulator.push(keysToCover[index]) 14 | } 15 | return accumulator 16 | } 17 | 18 | function pathIsCovered(path) { 19 | return coveragePaths.some(coveragePath => { 20 | for (let i = 0; i < coveragePath.length && i < path.length; i++) { 21 | const coveragePathSegment = coveragePath[i] 22 | const pathSegment = path[i] 23 | if (coveragePathSegment !== pathSegment) { 24 | return false 25 | } 26 | } 27 | return true 28 | }) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Kent C. Dodds 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/configValidator/index.js: -------------------------------------------------------------------------------- 1 | import {groupBy} from 'lodash' 2 | import checkConfig from '../checkConfig' 3 | import uncoveredPaths from '../uncoveredPaths' 4 | import formatMessagesAsString from '../formatMessagesAsString' 5 | 6 | export default configValidator 7 | 8 | function configValidator(configName, config, validators) { 9 | const uncoveredFieldsWarnings = getWarningsForUncoveredFields(config, validators) 10 | const validationWarningsAndErrors = checkConfig(config, validators) 11 | const warningsAndErrors = [...uncoveredFieldsWarnings, ...validationWarningsAndErrors] 12 | const {warning: warnings = [], error: errors = []} = groupBy(warningsAndErrors, 'type') 13 | 14 | return { 15 | errors, 16 | warnings, 17 | warningMessage: formatMessagesAsString(warnings), 18 | errorMessage: formatMessagesAsString(errors), 19 | } 20 | } 21 | 22 | function getWarningsForUncoveredFields(config, validators) { 23 | return uncoveredPaths(config, validators) 24 | .map(uncoveredPath => { 25 | return { 26 | key: uncoveredPath, 27 | message: 'Unknown key', 28 | type: 'warning', 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/checkConfig/index.js: -------------------------------------------------------------------------------- 1 | import {get, reduce, isUndefined, toPath, take} from 'lodash' 2 | import reformatResult from './reformatResult' 3 | 4 | export default checkConfig 5 | 6 | function checkConfig(config, validators) { 7 | return reduce(validators, getValidatorReducer(config), []) 8 | } 9 | 10 | function getValidatorReducer(config) { 11 | return validatorReducer 12 | 13 | function validatorReducer(accumulator, validator) { 14 | const {key} = validator 15 | const value = get(config, key) 16 | if (!isUndefined(value)) { 17 | const context = getContext(config, key) 18 | const result = validator.validate(value, context) 19 | if (result) { 20 | const {message, type} = reformatResult(result, validator) 21 | accumulator.push({ 22 | key, 23 | message, 24 | value, 25 | validator, 26 | type, 27 | }) 28 | } 29 | } 30 | return accumulator 31 | } 32 | } 33 | 34 | function getContext(config, key) { 35 | const path = toPath(key) 36 | const parentPath = take(path, path.length - 1) 37 | const parent = get(config, parentPath) 38 | return { 39 | key, 40 | parent, 41 | config, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/formatMessagesAsString/index.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | /** 4 | * @param {Array} messages: An array of message objects. 5 | * @returns {String} A colored string representation of the given message objects. 6 | */ 7 | export default function formatMessagesAsString(messages) { 8 | // Return undefined when there are no messages to format 9 | if (!messages.length) { 10 | return undefined 11 | } 12 | 13 | return messages.reduce(messageReducer, '') 14 | 15 | function messageReducer(accumulatorString, messageObject, index, array) { 16 | // Don't append newline to the last entry 17 | const newline = index === array.length - 1 ? '\n' : '' 18 | 19 | const formattedMessage = formatMessage(messageObject) 20 | return `${accumulatorString}${newline}${formattedMessage}\n` 21 | } 22 | } 23 | 24 | /** 25 | * @returns {String} A string displaying the color-coded key followed by the message string 26 | */ 27 | export function formatMessage({type, message, key}) { 28 | const keyColor = type === 'error' ? 'red' : 'yellow' 29 | const formattedKey = chalk.bold[keyColor](`${key}`) 30 | const formattedMessage = chalk.gray(message) 31 | return `${formattedKey}: ${formattedMessage}` 32 | } 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Working on your first Pull Request?** You can learn how from this *free* series 4 | [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 5 | 6 | ## Project setup 7 | 8 | 1. Fork and clone the repo 9 | 2. `$ npm install` to install dependencies 10 | 3. `$ npm run validate` to validate you've got it working 11 | 4. Create a branch for your PR 12 | 13 | ## Help wanted 14 | 15 | See the open issues labeled [`help wanted`](https://github.com/kentcdodds/configuration-validator/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) 16 | 17 | ## Contributing validators 18 | 19 | This project is intentionally void of any validators. It is intended to be pluggable 20 | so you can create other modules that are a collection of validators. 21 | 22 | ## Committing and Pushing changes 23 | 24 | This project uses (or soon will) [`semantic-release`](http://npm.im/semantic-release) 25 | to do automatic releases and generate a changelog based on the commit history. So we 26 | follow [a convention](https://github.com/stevemao/conventional-changelog-angular/blob/master/convention.md) 27 | for commit messages. Please follow this convention for your commit messages. 28 | 29 | -------------------------------------------------------------------------------- /src/uncoveredPaths/pathDiff/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import pathDiff from './index' 3 | 4 | const coveredPaths = [ 5 | [ 6 | [], 7 | [], 8 | ], 9 | [ 10 | [], 11 | ['foo'], 12 | ], 13 | [ 14 | ['foo'], 15 | ['foo'], 16 | ], 17 | [ 18 | ['foo'], 19 | ['foo', 'bar'], 20 | ], 21 | [ 22 | ['foo.baz', 'foo.bar'], 23 | ['foo'], 24 | ], 25 | [ 26 | ['foo.bat', 'foo.buz', 'foo.foo.bar', 'foo.foo.baz'], 27 | ['foo.bat', 'foo.buz', 'foo.foo'], 28 | ], 29 | ] 30 | 31 | test('covered config/validator pairs should pass', t => { 32 | coveredPaths.forEach(([config, validators]) => { 33 | t.true(pathDiff(config, validators).length === 0, `config: ${config} ; validators: ${validators}`) 34 | }) 35 | }) 36 | 37 | const uncoveredPaths = [ 38 | [ 39 | ['foo'], 40 | [], 41 | ], 42 | [ 43 | ['foo'], 44 | ['bar'], 45 | ], 46 | [ 47 | ['foo.baz', 'foo.bar'], 48 | ['foo.baz'], 49 | ], 50 | [ 51 | ['foo.bat', 'foo.buz', 'foo.foo.bar', 'foo.foo.baz'], 52 | ['foo.bat', 'foo.buz', 'foo.foo.baz'], 53 | ], 54 | ] 55 | 56 | test('uncovered config/validator pairs should fail', t => { 57 | uncoveredPaths.forEach(([config, validators]) => { 58 | t.true(pathDiff(config, validators).length === 1, `config: ${config} ; validators: ${validators}`) 59 | }) 60 | }) 61 | 62 | -------------------------------------------------------------------------------- /src/formatMessagesAsString/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import chalk from 'chalk' 3 | import formatMessagesAsString, {formatMessage} from './index' 4 | 5 | test('returns a colored string for an array of message objects', t => { 6 | const errorMessages = [ 7 | {type: 'error', message: 'error message', key: 'the.first.key'}, 8 | {type: 'warning', message: 'warning message', key: 'the.second.key'}, 9 | ] 10 | const messageString = formatMessagesAsString(errorMessages) 11 | const lines = messageString.split('\n') 12 | 13 | // Each error is formatted as a line followed by an empty line 14 | t.ok(lines.length === 4) 15 | 16 | t.ok(messageString.match(/the\.first\.key.*?error message/)) 17 | t.ok(messageString.match(/the\.second\.key.*?warning message/)) 18 | }) 19 | 20 | test('warning message string representation', t => { 21 | const message = {type: 'warning', message: 'warning message', key: 'the.first.key'} 22 | const formattedMessage = formatMessage(message) 23 | const expectedColor = 'yellow' 24 | t.same(formattedMessage, `${chalk.bold[expectedColor](message.key)}: ${chalk.gray(message.message)}`) 25 | }) 26 | 27 | test('error message string representation', t => { 28 | const message = {type: 'error', message: 'error message', key: 'the.first.key'} 29 | const formattedMessage = formatMessage(message) 30 | const expectedColor = 'red' 31 | t.same(formattedMessage, `${chalk.bold[expectedColor](message.key)}: ${chalk.gray(message.message)}`) 32 | }) 33 | -------------------------------------------------------------------------------- /src/uncoveredPaths/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import pathDiff from './index' 3 | 4 | const coveredPaths = [ 5 | { 6 | config: { 7 | bar: '', 8 | baz: false, 9 | }, 10 | validators: [ 11 | {key: 'bar'}, 12 | {key: 'baz'}, 13 | ], 14 | }, 15 | { 16 | config: { 17 | bar: { 18 | baz: [], 19 | }, 20 | foo: { 21 | foobar: false, 22 | spam: true, 23 | }, 24 | }, 25 | validators: [ 26 | {key: 'bar.baz'}, 27 | {key: 'foo'}, 28 | ], 29 | }, 30 | ] 31 | 32 | coveredPaths.forEach(({config, validators}, index) => { 33 | test(`passing config/validator pair ${index} should pass`, t => { 34 | t.true( 35 | pathDiff(config, validators).length === 0, 36 | `config: ${JSON.stringify(config)} ; validators: ${JSON.stringify(validators)}` 37 | ) 38 | }) 39 | }) 40 | 41 | const uncoveredPaths = [ 42 | { 43 | config: { 44 | bar: '', 45 | baz: false, 46 | }, 47 | validators: [ 48 | {key: 'baz'}, 49 | ], 50 | }, 51 | { 52 | config: { 53 | bar: { 54 | baz: [], 55 | }, 56 | foo: { 57 | foobar: false, 58 | spam: true, 59 | }, 60 | }, 61 | validators: [ 62 | {key: 'bar.baz'}, 63 | {key: 'foo.spam'}, 64 | ], 65 | }, 66 | ] 67 | 68 | test('passing config/validator pairs should pass', t => { 69 | uncoveredPaths.forEach(({config, validators}) => { 70 | t.true( 71 | pathDiff(config, validators).length === 1, 72 | `config: ${JSON.stringify(config)} ; validators: ${JSON.stringify(validators)}` 73 | ) 74 | }) 75 | }) 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/checkConfig/reformatResult/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {isMatch} from 'lodash' 3 | import reformatResult from './index' 4 | 5 | test('throws an error with a result of the incorrect type', t => { 6 | const resultStub = false 7 | const validator = {key: 'the key'} 8 | t.throws( 9 | () => reformatResult(resultStub, validator), 10 | /config-validator.*?false.*?the key/ 11 | ) 12 | }) 13 | 14 | test('throws an error with an improper object result', t => { 15 | const resultStub = {bar: 'baz'} 16 | const validator = {description: 'the description'} 17 | t.throws( 18 | () => reformatResult(resultStub, validator), 19 | /config-validator.*?\{"bar":"baz"\}.*?the description/ 20 | ) 21 | }) 22 | 23 | test('returns a type error and message of result if result is a string', t => { 24 | const resultStub = 'result stub' 25 | const result = reformatResult(resultStub) 26 | t.true(isMatch(result, { 27 | type: 'error', 28 | message: 'result stub', 29 | })) 30 | }) 31 | 32 | test('returns a type error and message of result.error', t => { 33 | const resultStub = {error: 'result error'} 34 | const result = reformatResult(resultStub) 35 | t.true(isMatch(result, { 36 | type: 'error', 37 | message: 'result error', 38 | })) 39 | }) 40 | 41 | test('returns a type warning and error of result.error', t => { 42 | const resultStub = {error: 'result error'} 43 | const result = reformatResult(resultStub) 44 | t.true(isMatch(result, { 45 | type: 'error', 46 | message: 'result error', 47 | })) 48 | }) 49 | 50 | test('returns a type warning and message of result.warning', t => { 51 | const resultStub = {warning: 'result warning'} 52 | const result = reformatResult(resultStub) 53 | t.true(isMatch(result, { 54 | type: 'warning', 55 | message: 'result warning', 56 | })) 57 | }) 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/checkConfig/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {isMatch} from 'lodash' 3 | import checkConfig from './index' 4 | 5 | test('can fail', t => { 6 | const validators = [ 7 | {key: 'foo.bar.baz', validate: () => 'foo error', description: 'foo bar baz thing'}, 8 | ] 9 | const config = {foo: {bar: {baz: 'error stuff'}}} 10 | const result = checkConfig(config, validators) 11 | const [item] = result 12 | t.true(isMatch(item, { 13 | key: 'foo.bar.baz', 14 | message: 'foo error', 15 | value: 'error stuff', 16 | validator: validators[0], 17 | type: 'error', 18 | })) 19 | }) 20 | 21 | test('can pass', t => { 22 | const validators = [getPassingValidator({key: 'foo'})] 23 | const config = {foo: 'working stuff'} 24 | const result = checkConfig(config, validators) 25 | noErrors(t, result) 26 | }) 27 | 28 | test('can warn', t => { 29 | const key = 'foo' 30 | const message = 'warning about foo' 31 | const value = 'foo value' 32 | const validators = [ 33 | { 34 | key, 35 | validate: () => ({warning: message}), 36 | }, 37 | ] 38 | const config = {[key]: value} 39 | const result = checkConfig(config, validators) 40 | const [warning] = result 41 | t.true(isMatch(warning, { 42 | key, 43 | message, 44 | value, 45 | validator: validators[0], 46 | type: 'warning', 47 | })) 48 | }) 49 | 50 | test('doesnot check non-existing keys', t => { 51 | const validators = [ 52 | getPassingValidator({key: 'foo'}), 53 | getFailingValidator({key: 'bar'}), 54 | ] 55 | const config = {foo: true} 56 | const result = checkConfig(config, validators) 57 | noErrors(t, result) 58 | }) 59 | 60 | test('doesnot check non-exsisting validators', t => { 61 | const validators = [ 62 | getFailingValidator({key: 'baz'}), 63 | ] 64 | const config = {foo: true} 65 | const result = checkConfig(config, validators) 66 | noErrors(t, result) 67 | }) 68 | 69 | function noErrors(t, result) { 70 | t.true(result.length === 0) 71 | } 72 | 73 | function getFailingValidator(overrides) { 74 | return { 75 | key: 'failing.property', 76 | validate: () => 'failed prop', 77 | description: 'Always Failing Prop', 78 | ...overrides, 79 | } 80 | } 81 | 82 | function getPassingValidator(overrides) { 83 | return { 84 | key: 'passing.property', 85 | validate: () => undefined, 86 | description: 'Always Passing Prop', 87 | ...overrides, 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /src/validate-reducer/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {spy} from 'sinon' 3 | 4 | import {validateReducer} from './' 5 | const {EXIT_EARLY, CONTINUE} = validateReducer 6 | 7 | test('returns the message of the first message returning validator', t => { 8 | const warning = {warning: 'WATCH OUT!'} 9 | 10 | const v1 = spy(() => CONTINUE) 11 | const v2 = spy(() => warning) 12 | const v3 = spy(() => CONTINUE) 13 | const result = validateReducer([v1, v2, v3]) 14 | t.same(result, warning) 15 | t.true(v1.calledOnce) 16 | t.true(v2.calledOnce) 17 | t.false(v3.called) 18 | }) 19 | 20 | test('calls all validators if they all return continue', t => { 21 | const v1 = spy(() => CONTINUE) 22 | const v2 = spy(() => CONTINUE) 23 | const v3 = spy(() => CONTINUE) 24 | const result = validateReducer([v1, v2, v3]) 25 | t.same(result, undefined) 26 | t.true(v1.calledOnce) 27 | t.true(v2.calledOnce) 28 | t.true(v3.calledOnce) 29 | }) 30 | 31 | test('stops calling validators when the first validator that returns EXIT_EARLY is called', t => { 32 | const v1 = spy(() => CONTINUE) 33 | const v2 = spy(() => EXIT_EARLY) 34 | const v3 = spy(() => CONTINUE) 35 | const result = validateReducer([v1, v2, v3]) 36 | t.same(result, undefined) 37 | t.true(v1.calledOnce) 38 | t.true(v2.calledOnce) 39 | t.false(v3.called) 40 | }) 41 | 42 | test('stops calling validators when the first validator that returns a string is called', t => { 43 | const interestingFact = 'The tallest artificial structure is the 829.8 m (2,722 ft) tall Burj Khalifa in Dubai, United Arab Emirates' // eslint-disable-line max-len 44 | 45 | const v1 = spy(() => CONTINUE) 46 | const v2 = spy(() => interestingFact) 47 | const v3 = spy(() => CONTINUE) 48 | const result = validateReducer([v1, v2, v3]) 49 | t.same(result, interestingFact) 50 | t.true(v1.calledOnce) 51 | t.true(v2.calledOnce) 52 | t.false(v3.called) 53 | }) 54 | 55 | /* 56 | * This test is basically to tell you that returning `undefined` is just like returning 57 | * EXIT_EARLY, except it's less explicit which is not cool 👎 58 | */ 59 | test('stops calling validators when the first validator that returns nothing is called', t => { 60 | const v1 = spy(() => CONTINUE) 61 | const v2 = spy(() => undefined) 62 | const v3 = spy(() => CONTINUE) 63 | const result = validateReducer([v1, v2, v3]) 64 | t.same(result, undefined) 65 | t.true(v1.calledOnce) 66 | t.true(v2.calledOnce) 67 | t.false(v3.called) 68 | }) 69 | 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "configuration-validator", 3 | "version": "1.0.0-beta.9", 4 | "description": "Validate your webpack configuration and save yourself hours of debugging time", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "nyc": { 10 | "exclude": [ 11 | "**/*.test.js" 12 | ] 13 | }, 14 | "scripts": { 15 | "precommit": "npm-run-all --parallel lint cover build --sequential check-coverage", 16 | "prebuild": "rimraf dist", 17 | "build": "babel --ignore src/**/*.test.js -d dist src", 18 | "cover": "nyc --reporter=lcov --reporter=text --reporter=html ava", 19 | "check-coverage": "nyc check-coverage --statements 100 --branches 100 --functions 100 --lines 100", 20 | "report-coverage": "cat ./coverage/lcov.info | node_modules/.bin/codecov", 21 | "lint": "eslint .", 22 | "test": "ava", 23 | "watch:test": "ava -w", 24 | "watch:cover": "nodemon --quiet --watch src --exec npm run cover -s", 25 | "validate": "npm-run-all --parallel lint cover build --sequential check-coverage", 26 | "release": "npm run build && with-package git commit -am pkg.version && with-package git tag pkg.version && git push && npm publish && git push --tags", 27 | "release:beta": "npm run release && npm run tag:beta", 28 | "tag:beta": "with-package npm dist-tag add pkg.name@pkg.version beta" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/kentcdodds/configuration-validator.git" 33 | }, 34 | "keywords": [], 35 | "author": "Kent C. Dodds (http://kentcdodds.com/)", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/kentcdodds/webpack-validator/issues" 39 | }, 40 | "homepage": "https://github.com/kentcdodds/webpack-validator#readme", 41 | "dependencies": { 42 | "chalk": "^1.1.1", 43 | "lodash": "^4.3.0" 44 | }, 45 | "devDependencies": { 46 | "ava": "0.13.0", 47 | "babel-cli": "6.6.5", 48 | "babel-core": "6.7.2", 49 | "babel-preset-es2015": "6.6.0", 50 | "babel-preset-stage-2": "6.5.0", 51 | "babel-register": "6.7.2", 52 | "codecov": "1.0.1", 53 | "eslint": "2.4.0", 54 | "eslint-config-kentcdodds": "6.0.0", 55 | "ghooks": "1.0.3", 56 | "nodemon": "1.9.1", 57 | "npm-run-all": "1.5.3", 58 | "nyc": "6.1.1", 59 | "rimraf": "2.5.2", 60 | "sinon": "1.17.3", 61 | "validate-commit-msg": "2.4.0", 62 | "with-package": "0.2.0" 63 | }, 64 | "eslintConfig": { 65 | "extends": "kentcdodds" 66 | }, 67 | "babel": { 68 | "presets": [ 69 | "es2015", 70 | "stage-2" 71 | ] 72 | }, 73 | "config": { 74 | "ghooks": { 75 | "commit-msg": "validate-commit-msg", 76 | "pre-commit": "npm run precommit" 77 | } 78 | }, 79 | "ava": { 80 | "files": [ 81 | "src/**/*.test.js" 82 | ], 83 | "source": [ 84 | "./src/**/*.js", 85 | "!dist/**/*" 86 | ], 87 | "require": [ 88 | "babel-register" 89 | ], 90 | "babel": "inherit" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/uncoveredPaths/getAllPaths/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {isEqual} from 'lodash' 3 | import getAllPaths from './index' 4 | 5 | test('paths for an empty object returns an empty array', t => { 6 | const input = {} 7 | const result = getAllPaths(input) 8 | t.true(result.length === 0) 9 | }) 10 | 11 | test('returns an empty array for non-plain object', t => { 12 | const input = 'foo' 13 | const result = getAllPaths(input) 14 | t.true(result.length === 0) 15 | }) 16 | 17 | test('paths for an object with only primative properties returns a simple array', t => { 18 | const input = { 19 | a: 'hi', 20 | b: true, 21 | c: 23, 22 | d: null, 23 | } 24 | const result = getAllPaths(input) 25 | t.true(isEqual(result, [ 26 | 'a', 'b', 'c', 'd', 27 | ])) 28 | }) 29 | 30 | test('paths for an object with deep properties returns an array of the deepest of all properties', t => { 31 | const input = { 32 | level1: { 33 | level2: { 34 | level3: { 35 | leaf: true, 36 | }, 37 | level32: { 38 | level4: { 39 | leaf: 'hi', 40 | }, 41 | }, 42 | }, 43 | }, 44 | } 45 | const result = getAllPaths(input) 46 | t.true(isEqual(result, [ 47 | 'level1.level2.level3.leaf', 48 | 'level1.level2.level32.level4.leaf', 49 | ])) 50 | }) 51 | 52 | test('stops at arrays', t => { 53 | const input = { 54 | level1: { 55 | level2: { 56 | leaf: [ 57 | { 58 | foo: { 59 | bar: 'baz', 60 | }, 61 | }, 62 | ], 63 | }, 64 | level22: { 65 | level3: { 66 | leaf: [ 67 | { 68 | foobar: { 69 | spam: 'eggs', 70 | }, 71 | }, 72 | ], 73 | }, 74 | }, 75 | }, 76 | } 77 | const result = getAllPaths(input) 78 | t.true(isEqual(result, [ 79 | 'level1.level2.leaf', 80 | 'level1.level22.level3.leaf', 81 | ])) 82 | }) 83 | 84 | test('handles recursive structures without blowing up', t => { 85 | const input1 = {} 86 | const input2 = {input1} 87 | input1.input2 = input2 88 | const result = getAllPaths(input1) 89 | t.true(isEqual(result, ['input2'])) 90 | }) 91 | 92 | /* 93 | * Tests below here are used to fix bugs (and keep them from coming back) 94 | */ 95 | test('objects with keys who\'s values are the same as other keys', t => { 96 | const input = { 97 | externals: { 98 | angular: 'angular', 99 | 'api-check': { 100 | root: 'apiCheck', 101 | amd: 'api-check', 102 | commonjs2: 'api-check', 103 | commonjs: 'api-check', 104 | }, 105 | }, 106 | } 107 | const result = getAllPaths(input) 108 | t.true(isEqual(result, [ 109 | 'externals.angular', 110 | 'externals.api-check.root', 111 | 'externals.api-check.amd', 112 | 'externals.api-check.commonjs2', 113 | 'externals.api-check.commonjs', 114 | ])) 115 | }) 116 | 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configuration Validator 2 | 3 | Built as the pluggable validator for [webpack-validator](https://github.com/kentcdodds/webpack-validator) 4 | but could be used to validate any configuration object. 5 | 6 | [![Build Status](https://img.shields.io/travis/kentcdodds/configuration-validator.svg?style=flat-square)](https://travis-ci.org/kentcdodds/configuration-validator) 7 | [![Code Coverage](https://img.shields.io/codecov/c/github/kentcdodds/configuration-validator.svg?style=flat-square)](https://codecov.io/github/kentcdodds/configuration-validator) 8 | [![version](https://img.shields.io/npm/v/configuration-validator.svg?style=flat-square)](http://npm.im/configuration-validator) 9 | [![downloads](https://img.shields.io/npm/dm/configuration-validator.svg?style=flat-square)](http://npm-stat.com/charts.html?package=configuration-validator) 10 | [![MIT License](https://img.shields.io/npm/l/configuration-validator.svg?style=flat-square)](http://opensource.org/licenses/MIT) 11 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 12 | 13 | ## Usage 14 | 15 | ```javascript 16 | var configValidator = require('configuration-validator') 17 | 18 | // call the function with your config and validators 19 | configValidator('Name of config', config, arrayOfValidators) 20 | ``` 21 | 22 | ## Validators 23 | 24 | A validator is an object that describes to configuration-validator what it should run on and 25 | defines a function to run to do the validation. Here's an example of a simple validator that 26 | checks for the value to be a boolean: 27 | 28 | ```javascript 29 | { 30 | key: 'foo.bar', // can be any depth. Uses lodash's `get` method 31 | description: 'Some descriptive message', // used in verbose mode (in development) 32 | validate: function validateFooBar(value, context) { 33 | // value is the value of `foo.bar` for the given object 34 | // context is an object with the following properties: 35 | // - `key` - `foo.bar` in this case 36 | // - `parent` - the parent object in which this value is found 37 | // - `config` - the entire object being validated 38 | 39 | if (typeof value !== 'boolean') { 40 | // if it's not a boolean, then we fail the validation. In this case 41 | // we return a message that is an error or a warning 42 | return 'must be a boolean' // just shorthand for: {error: 'must be a boolean'} 43 | // or you can do: 44 | // return {warning: 'must be a boolean'} // output is yellow 45 | // return {error: 'must be a boolean'} // output is red 46 | } 47 | }, 48 | } 49 | ``` 50 | 51 | ## Uncovered Path Validation 52 | 53 | One handy feature of the `configuration-validator` is that it will warn if your object has a path that's not covered by validation keys. For example, consider the following config: 54 | 55 | ```javascript 56 | // config 57 | const config = { 58 | bar: { 59 | foo: true, 60 | foobar: 2, 61 | }, 62 | baz: 32, 63 | } 64 | ``` 65 | 66 | There are three paths associated with this config object: `bar.foo`, `bar.foobar`, and `baz`. 67 | 68 | Given the following validators: 69 | 70 | ```javascript 71 | const validators = [ 72 | {key: 'bar.foo', validate() {}}, 73 | {key: 'baz', validate() {}}, 74 | ] 75 | ``` 76 | 77 | You will get a warning indicating that `bar.foobar` is an uncovered path (meaning it's not validated). 78 | 79 | To cover all paths, you could simply add a validotor for `bar.foobar` or add a validator for the entire `bar` path like so: 80 | 81 | ```javascript 82 | const validators = [ 83 | {key: 'bar', validate() {}}, 84 | {key: 'baz', validate() {}}, 85 | ] 86 | ``` 87 | 88 | One issue you might have with this is currently you lose the deep path check because the entire object is considered to be covered. So, if you were to misspell the `foo` property, you wouldn't get the warning. 89 | 90 | Catching misspellings is the biggest benefit of the uncovered path validation feature, so it's normally best to try to avoid doing validation on entire objects if possible and try to validate privitives and arrays only. 91 | 92 | ## LICENSE 93 | 94 | MIT 95 | 96 | -------------------------------------------------------------------------------- /src/configValidator/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import test from 'ava' 3 | import sinon from 'sinon' 4 | 5 | import configValidator from './index' 6 | 7 | const EMPTY_RESULT = { 8 | errors: [], 9 | warnings: [], 10 | warningMessage: undefined, 11 | errorMessage: undefined, 12 | } 13 | 14 | test('No errors or warnings result in an return object with sensible "empty" values', t => { 15 | const result = configValidator('Webpack Config', {}, []) 16 | t.same(result, EMPTY_RESULT) 17 | }) 18 | 19 | test('Errors are returned in the `errors` property of the return object ' + 20 | 'and as formatted string under `errorMessage`', t => { 21 | const config = {bar: 'baz'} 22 | const validators = [{key: 'bar', validate: () => 'error'}] 23 | const result = configValidator('Webpack Config', config, validators) 24 | t.same(result.errors, [ 25 | { 26 | key: 'bar', 27 | message: 'error', 28 | value: 'baz', 29 | validator: validators[0], 30 | type: 'error', 31 | }, 32 | ]) 33 | t.ok(result.errorMessage) // Test implementation details of this in `formatMessagesAsString` tests 34 | }) 35 | 36 | test('Warnings are returned in the `warnings` property of the return object ' + 37 | 'and as formatted string under `warningMessage`', t => { 38 | const config = {bar: 'baz'} 39 | const validators = [{key: 'bar', validate: () => ({warning: 'warning'})}] 40 | const result = configValidator('Webpack Config', config, validators) 41 | t.same(result.warnings, [ 42 | { 43 | key: 'bar', 44 | message: 'warning', 45 | value: 'baz', 46 | validator: validators[0], 47 | type: 'warning', 48 | }, 49 | ]) 50 | t.ok(result.warningMessage) // Test implementation details of this in `formatMessagesAsString` tests 51 | }) 52 | 53 | test('Unknown fields result in a warning', t => { 54 | const config = {bar: 'baz', cat: 'bag'} 55 | const validators = [{key: 'bar', validate: () => 'error'}] 56 | const result = configValidator('Webpack Config', config, validators) 57 | 58 | t.ok(result.errors.length === validators.length) 59 | t.ok(result.errorMessage) 60 | 61 | t.ok(result.warnings.length === 1) 62 | t.same(result.warnings[0], { 63 | key: 'cat', 64 | message: 'Unknown key', 65 | type: 'warning', 66 | }) 67 | t.ok(result.warningMessage) 68 | }) 69 | 70 | test('Nested fields should not trigger warnings if they validate', t => { 71 | const config = { 72 | foo: { 73 | bar: true, 74 | baz: 23, 75 | }, 76 | } 77 | const validators = [ 78 | {key: 'foo.bar', validate() {}}, 79 | {key: 'foo.baz', validate() {}}, 80 | ] 81 | const result = configValidator('Webpack Config', config, validators) 82 | t.same(result, EMPTY_RESULT) 83 | }) 84 | 85 | test('Null fields should not trigger warnings if they validate', t => { 86 | const config = { 87 | foo: null, 88 | help: 'me', 89 | } 90 | const validators = [ 91 | {key: 'foo', validate() {}}, 92 | {key: 'help', validate() {}}, 93 | ] 94 | const result = configValidator('Webpack Config', config, validators) 95 | t.same(result, EMPTY_RESULT) 96 | }) 97 | 98 | test('Array fields should not have nested checks', t => { 99 | const config = { 100 | foo: null, 101 | help: ['me', 'you', 'them'], 102 | } 103 | const validators = [ 104 | {key: 'foo', validate() {}}, 105 | {key: 'help', validate() {}}, 106 | ] 107 | const result = configValidator('Webpack Config', config, validators) 108 | t.same(result, EMPTY_RESULT) 109 | }) 110 | 111 | test('Nested fields trigger when not validated', t => { 112 | const config = { 113 | foo: { 114 | bar: true, 115 | baz: 23, 116 | }, 117 | } 118 | const validators = [ 119 | {key: 'foo.bar', validate() {}}, 120 | ] 121 | const result = configValidator('Webpack Config', config, validators) 122 | 123 | t.ok(result.warnings.length === 1) 124 | t.same(result.warnings[0], { 125 | key: 'foo.baz', 126 | message: 'Unknown key', 127 | type: 'warning', 128 | }) 129 | }) 130 | 131 | test('Deeply nested fields trigger when not validated', t => { 132 | const config = { 133 | foo: { 134 | bar: { 135 | cat: 'sink', 136 | }, 137 | baz: 23, 138 | }, 139 | } 140 | const validators = [ 141 | {key: 'foo.bar.cat', validate: () => 'error'}, 142 | ] 143 | const result = configValidator('Webpack Config', config, validators) 144 | t.ok(result.errors.length === 1) 145 | t.same(result.errors[0], { 146 | key: 'foo.bar.cat', 147 | message: 'error', 148 | type: 'error', 149 | validator: validators[0], 150 | value: 'sink', 151 | }) 152 | }) 153 | 154 | test('Does not log anything, that\'s left to the API consumer', t => { 155 | const consoleLogSpy = sinon.spy(console, 'log') 156 | 157 | const config = {bar: 'baz'} 158 | const validators = [{key: 'bar', validate: () => ({warning: 'warning'})}] 159 | configValidator('Webpack Config', config, validators) 160 | t.true(consoleLogSpy.callCount === 0) 161 | 162 | consoleLogSpy.restore() 163 | }) 164 | --------------------------------------------------------------------------------