├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── webpack.config.js ├── webpack.dev.config.js └── webpack.production.config.js ├── package-lock.json ├── package.json ├── src ├── assert.js ├── index.js ├── rule.js ├── utils.js └── validation.js └── test ├── rule.spec.js ├── rule ├── test-config.js └── test-validation.js └── validation.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["> 1%", "last 2 versions", "ie > 9", "not ie <= 9", "safari > 8", "not safari <= 8"] 6 | }, 7 | "modules": false 8 | }], 9 | "stage-2" 10 | ], 11 | "env": { 12 | "test": { 13 | "presets": [ 14 | ["env", { 15 | "modules": "commonjs" 16 | }] 17 | ] 18 | }, 19 | "production": { 20 | "plugins": ["lodash"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | Last 2 versions 3 | IE > 9 4 | not IE <= 9 5 | Safari > 8 6 | not Safari <= 8 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "airbnb-base", 3 | parserOptions: { 4 | ecmaVersion: 8, 5 | sourceType: "module", 6 | ecmaFeatures: { 7 | experimentalObjectRestSpread: true 8 | } 9 | }, 10 | globals: { 11 | expect: true, 12 | it: true, 13 | describe: true 14 | }, 15 | settings: { 16 | "import/resolver": { 17 | webpack: { 18 | config: "./config/webpack.config.js" 19 | } 20 | } 21 | }, 22 | rules: { 23 | semi: ["error", "never"], 24 | "one-var": ["error", "always"], 25 | "no-underscore-dangle": 0, 26 | "comma-dangle": ["error", "never"], 27 | "max-len": ["error", 80] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | 5 | after_script: 6 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 7 | - rm -rf ./coverage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Komlev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forx 2 | Javascript validation library. 3 | 4 | [![Coverage Status](https://coveralls.io/repos/github/komlev/forx/badge.svg?branch=master)](https://coveralls.io/github/komlev/forx?branch=master) 5 | 6 | [JS data validation with Forx](https://medium.com/@komlev88/js-data-validation-with-forx-ee06986393af) 7 | 8 | ## Validation 9 | Configuration is written as an array of validation objects. 10 | 11 | Suppose this is the data we validate on: 12 | ```js 13 | { 14 | name: 'Name', 15 | age: 20, 16 | address: { line1: 'Line 1' }, 17 | hasTeam: true, 18 | team: [ 19 | { 20 | name: 'Team member 1', 21 | position: { 22 | role: 'Manager' 23 | }, 24 | address: [ 25 | { line1: 'Line 1', postCode: { value: 1 } }, 26 | { line1: 'Line 2', postCode: { value: 2 } } 27 | ] 28 | }, 29 | { 30 | name: 'Team member 2', 31 | position: { 32 | role: 'Manager' 33 | }, 34 | address: [ 35 | { line1: 'Line 3', postCode: { value: 3 } }, 36 | { line1: 'Line 4', postCode: { value: 4 } } 37 | ] 38 | } 39 | ] 40 | } 41 | ``` 42 | Validation on fields like **name**, **age**, **hasTeam** is trivial, but validation on nested objects, arrays and combination of those might be problematic. **Forx** deals with this problem. 43 | 44 | And here is a validation config: 45 | Here is how validation config looks like: 46 | ```js 47 | [ 48 | // validation on age field 49 | { 50 | value: 'age', 51 | test: [ 52 | [required, 'empty'], 53 | [min(18), () => 'min'] 54 | ] 55 | }, 56 | // validation on nested field 57 | { 58 | value: 'address.line1', 59 | test: [ 60 | [required, 'empty'] 61 | ] 62 | }, 63 | // validation on object inside an array 64 | { 65 | value: 'team.name', 66 | params: ['team'], 67 | test: [ 68 | [required, 'empty'] 69 | ], 70 | enabled: [(name, team) => !!team] 71 | }, 72 | // deep nested validation 73 | { 74 | value: ['team', 'position', 'role'], 75 | params: ['team'], 76 | test: [ 77 | [required, () => 'empty'] 78 | ], 79 | enabled: [(role, team) => !!team] 80 | }, 81 | { 82 | value: 'name', 83 | to: 'nameError', 84 | test: [ 85 | [required, 'empty'] 86 | ] 87 | } 88 | ] 89 | ``` 90 | 91 | Validation on given data will return empty object - no errors. 92 | But if we validate on invalid data error object might look like this: 93 | ```js 94 | { 95 | age: ['empty', 'min'], 96 | address: { 97 | line: ['empty'] 98 | }, 99 | team: [ 100 | { 101 | name: ['empty'], 102 | position: { 103 | role: ['empty'] 104 | } 105 | } 106 | ], 107 | nameError: ['empty'] 108 | } 109 | ``` 110 | 111 | ## Validation objects 112 | 113 | Example of validate object: 114 | ```js 115 | { 116 | value: 'value', 117 | params: ['some.extra', 'data'], 118 | to: 'custom', 119 | test: [ 120 | [ 121 | (value, ...params) => value !== undefined, 122 | 'error message' 123 | ], 124 | [ 125 | (value, ...params) => value !== 0 ? true : 'error message' 126 | ] 127 | ] 128 | } 129 | ``` 130 | 131 | ### Required fields: 132 | + **value** - path. Main field against which validation will work. 133 | This field will go as first parameter into test/enabled/message functions. 134 | + **test** - array of arrays with this notation: 135 | ```js 136 | [testFunction, message] 137 | ``` 138 | 139 | Where **testFunction** is a function which receives data from **value** and **params** fields. 140 | *If function returns *true* then there is no error and no error message is returned.* 141 | *If function returns string this will be taken as an error.* 142 | *If function returns Boolean false **message** object will be taken into account.* 143 | **Message** might be string or function, which will be returned if validation function fails. 144 | 145 | ### Optional fields: 146 | 147 | + **params** - array of paths. Extra parameters you might like to have in your functions. 148 | + **to** - path. Instead of writing error into the same **value** path, error might be written into custom path provided. 149 | + **enabled** - array of values/functions which enables/disables validation functions. Might be useful disabling some validation based on inputs. 150 | 151 | ### Path 152 | 153 | Data is retrieved and written through the [q3000](https://www.npmjs.com/package/q3000) library. 154 | 155 | ```js 156 | const data = { 157 | simple: 'simple', 158 | nested: { nested2: { data: 1 }, nested3: ['a', 'b'] }, 159 | list: [ 160 | { nested4: { data: 2 } }, 161 | { nested4: { data: 3 } } 162 | ] 163 | } 164 | 165 | 'simple' // "simple" 166 | 'nested.nested2' // { data: 1 } 167 | 'nested.nested2.data' // 1 168 | 'nested.nested3.0' // 'a' 169 | 'list.nested4.data' // [ 2, 3 ] 170 | 'list.0.nested4.data' // 2 171 | 172 | //These are equivalents 173 | 'nested.nested3' // ['a', 'b'] 174 | 'nested.nested3.*' // ['a', 'b'] 175 | ['nested', 'nested3', '*'] // ['a', 'b'] 176 | 177 | //These are equivalents 178 | 'list' // [ { nested4: { data: 2 } }, { nested4:{ data: 3 } } ], 179 | 'list.*' // [ { nested4: { data: 2 } }, { nested4:{ data: 3 } } ] 180 | 'list[*]' // [ { nested4: { data: 2 } }, { nested4:{ data: 3 } } ] 181 | 182 | //These are equivalents 183 | 'list.0' // { nested4: { data: 2 } } 184 | 'list[0]' // { nested4:{ data: 3 } } 185 | 186 | //These are equivalents 187 | 'list.nested4' // [ { data: 2 }, { data: 3 } ] 188 | 'list.*.nested4' // [ { data: 2 }, { data: 3 } ] 189 | ``` 190 | 191 | ### Path context 192 | Assume you have this data 193 | ```js 194 | { 195 | team: [ 196 | { 197 | list: [ 198 | { list: [ { a:1, b: true }, { a:2, b: false } ] }, 199 | { list: [ { a:3, b: true }, { a:4, b: false } ] } 200 | ] 201 | }, 202 | { 203 | list: [ 204 | { list: [ { a:5, b: true }, { a:6, b: false } ] }, 205 | { list: [ { a:7, b: true }, { a:8, b: false } ] } 206 | ] 207 | } 208 | ] 209 | } 210 | ``` 211 | 212 | And idea is to validate *a* fields inside items in *team.list.list* path. 213 | You also need to get *b* field from the item you currently validating. Here is there context come into play. 214 | 215 | For this case validation config might look like this: 216 | ```js 217 | [ 218 | { 219 | value: 'team.list.list.a', 220 | params: ['{team}.{list}.{list2}.b'], 221 | to: '{team}.{list}.{list2}.customError', 222 | test: [ 223 | [(a, b) => (b && a > 4), 'error'] 224 | ] 225 | } 226 | ] 227 | ``` 228 | 229 | It is possible to use context inside path for **params** and **to** fields. 230 | It is possible to address current context via curly braces *{team}*. 231 | If path has identical names like in case with *list* it will automatically adds incremental IDs to new each name. *{list}.{list2}*. 232 | 233 | ## API 234 | 235 | There are 2 functions which library exposes: *makeConfig*, *run* 236 | 237 | ```js 238 | import { makeConfig, run } from 'forx' 239 | 240 | // prepare config for evaluation 241 | const prepConfig = makeConfig(validationConfig) 242 | // run prepared config on data 243 | const errors = run(prepConfig, data), 244 | errors2 = run(prepConfig, data), 245 | errors3 = run(prepConfig, data) 246 | ``` 247 | 248 | ## License 249 | MIT 250 | -------------------------------------------------------------------------------- /config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'), 2 | rootDirectory = resolve(__dirname, '../') 3 | 4 | module.exports = { 5 | entry: './index.js', 6 | output: { 7 | path: resolve(rootDirectory, 'lib'), 8 | filename: 'forx.js', 9 | sourceMapFilename: 'forx.map', 10 | library: 'forx', 11 | libraryTarget: 'umd' 12 | }, 13 | context: resolve(rootDirectory, 'src'), 14 | devtool: 'cheap-eval-source-map', 15 | resolve: { 16 | extensions: ['.js', '.json'], 17 | modules: [ 18 | resolve(rootDirectory, 'node_modules'), 19 | resolve(rootDirectory, 'src') 20 | ] 21 | }, 22 | module: { 23 | exprContextCritical: true, 24 | loaders: [ 25 | { 26 | test: /\.js?$/, 27 | exclude: /node_modules/, 28 | loaders: ['babel-loader'] 29 | } 30 | ] 31 | }, 32 | plugins: [] 33 | } 34 | -------------------------------------------------------------------------------- /config/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config'), 2 | webpack = require('webpack'), 3 | BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 4 | 5 | config.plugins = config.plugins.concat([ 6 | new BundleAnalyzerPlugin({ 7 | analyzerMode: 'static', 8 | openAnalyzer: false 9 | }) 10 | ]) 11 | 12 | module.exports = config 13 | -------------------------------------------------------------------------------- /config/webpack.production.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const config = require('./webpack.config'), 3 | webpack = require('webpack'), 4 | BundleAnalyzer = require('webpack-bundle-analyzer').BundleAnalyzerPlugin, 5 | LodashModuleReplacementPlugin = require('lodash-webpack-plugin') 6 | 7 | config.devtool = 'source-map' 8 | config.plugins = config.plugins.concat([ 9 | new webpack.DefinePlugin({ 10 | 'process.env.NODE_ENV': JSON.stringify('production') 11 | }), 12 | new LodashModuleReplacementPlugin({ 13 | currying: true, 14 | flattening: true, 15 | paths: true 16 | }), 17 | new webpack.LoaderOptionsPlugin({ 18 | minimize: true, 19 | debug: false 20 | }), 21 | new webpack.optimize.UglifyJsPlugin({ 22 | beautify: false, 23 | comments: false, 24 | sourceMap: 'source-map', 25 | mangle: { 26 | screw_ie8: true, 27 | keep_fnames: true 28 | }, 29 | compress: { 30 | warnings: false, 31 | screw_ie8: true, 32 | conditionals: true, 33 | unused: true, 34 | comparisons: true, 35 | sequences: true, 36 | dead_code: true, 37 | evaluate: true, 38 | if_return: true, 39 | join_vars: true 40 | } 41 | }), 42 | new BundleAnalyzer({ 43 | analyzerMode: 'static', 44 | openAnalyzer: false 45 | }) 46 | ]) 47 | 48 | module.exports = config -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forx", 3 | "version": "0.0.12", 4 | "description": "Validation library", 5 | "main": "lib/forx.js", 6 | "module": "src/index.js", 7 | "files": [ 8 | "lib/forx.js" 9 | ], 10 | "license": "MIT", 11 | "scripts": { 12 | "prebuild": "npm run clean", 13 | "predev": "npm run clean", 14 | "clean": "rimraf ./lib/*", 15 | "build": "cross-env NODE_ENV=production webpack --optimize-minimize -p --config config/webpack.production.config.js", 16 | "dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.config.js", 17 | "dev:watch": "cross-env NODE_ENV=development webpack --watch --config config/webpack.dev.config.js", 18 | "lint": "eslint ./src", 19 | "test": "jest --coverage", 20 | "test:watch": "jest --watch" 21 | }, 22 | "dependencies": { 23 | "lodash": "^4.17.4", 24 | "q3000": "0.0.9" 25 | }, 26 | "devDependencies": { 27 | "babel-core": "^6.25.0", 28 | "babel-loader": "^7.1.1", 29 | "babel-plugin-lodash": "^3.2.11", 30 | "babel-polyfill": "^6.23.0", 31 | "babel-preset-env": "^1.6.0", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-stage-2": "^6.24.1", 34 | "coveralls": "^3.0.0", 35 | "cross-env": "^5.0.5", 36 | "eslint": "^4.4.1", 37 | "eslint-config-airbnb-base": "^11.3.1", 38 | "eslint-plugin-import": "^2.7.0", 39 | "jest": "^20.0.4", 40 | "lodash-webpack-plugin": "^0.11.4", 41 | "rimraf": "^2.6.1", 42 | "webpack": "^3.5.3", 43 | "webpack-bundle-analyzer": "^2.9.0" 44 | }, 45 | "jest": { 46 | "collectCoverage": false, 47 | "testEnvironment": "node", 48 | "coverageDirectory": "./coverage", 49 | "coveragePathIgnorePatterns": [ 50 | "/node_modules/", 51 | "/lib/", 52 | "/test/" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/assert.js: -------------------------------------------------------------------------------- 1 | const isExisty = a => a !== undefined && a !== null 2 | 3 | export { 4 | isExisty // eslint-disable-line 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { run, makeConfig, runRaw } from './rule' 2 | 3 | export { 4 | run, 5 | runRaw, 6 | makeConfig 7 | } 8 | -------------------------------------------------------------------------------- /src/rule.js: -------------------------------------------------------------------------------- 1 | import { getPath, traverse, set } from 'q3000' // eslint-disable-line 2 | import { 3 | reduce, 4 | map, 5 | filter, 6 | compose, 7 | always, 8 | toPairs, 9 | isFunction, 10 | isArray, 11 | isObject, 12 | isEmpty, 13 | forEach, 14 | last, 15 | slice, 16 | cloneDeep 17 | } from 'lodash/fp' 18 | import { isExisty } from './assert' 19 | import { concat, arrayOfArrays, getContextPath } from './utils' 20 | import { validateItem } from './validation' 21 | 22 | const defaultEnabler = [() => true], 23 | normalizeParams = (params) => { 24 | let result = params 25 | if (isFunction(result)) return normalizeParams(result()) 26 | if (!isExisty(result)) return result 27 | if (!isArray(result)) result = [result] 28 | return map((p) => { 29 | if (isFunction(p)) return p 30 | return getPath(p) 31 | }, result) 32 | }, 33 | normalizeTest = (value) => { 34 | if (isFunction(value)) return normalizeTest(value()) 35 | if (!isExisty(value) || !isArray(value)) return value 36 | if (!arrayOfArrays(value)) return [value] 37 | return value 38 | }, 39 | mapEnablers = compose( 40 | map((v) => { 41 | if (!isFunction(v)) return always(v) 42 | return v 43 | }), 44 | filter(isExisty) 45 | ), 46 | normalizeEnabled = (value) => { 47 | if (!isExisty(value)) return defaultEnabler 48 | if (isFunction(value)) return [value] 49 | if (isArray(value)) return mapEnablers(value) 50 | return value 51 | }, 52 | normalizeRule = (rule) => { 53 | if (!isExisty(rule) || isFunction(rule)) return rule 54 | const value = getPath(rule.value), 55 | test = normalizeTest(rule.test), 56 | params = normalizeParams(rule.params), 57 | enabled = normalizeEnabled(rule.enabled) 58 | 59 | return { ...rule, value, test, params, enabled } 60 | }, 61 | queryPath = (value, context) => (p) => { 62 | if (isFunction(p)) { 63 | // clone to prevent anyone from messing with lib internals 64 | return p(cloneDeep({ 65 | current: context.current, 66 | indexes: context.indexes 67 | })) 68 | } 69 | const 70 | contextPath = getContextPath(p, context.indexes) 71 | 72 | if (last(contextPath) === '@') { 73 | return slice(0, contextPath.length - 1, contextPath) 74 | } 75 | return traverse(contextPath, value) 76 | }, 77 | queryPaths = (params, value, context) => 78 | map(queryPath(value, context), params), 79 | getRuleParams = (value, queryRes, params, context) => { 80 | let result = [queryRes] 81 | if (params) { 82 | result = concat(result, queryPaths(params, value, context), context) 83 | } else { 84 | result = concat(result, context) 85 | } 86 | 87 | return result 88 | }, 89 | isEnabled = (params, predicats) => 90 | reduce( 91 | (acc, f) => { 92 | if (f && isFunction(f)) return acc && f(...params) 93 | return acc 94 | }, 95 | true, 96 | predicats 97 | ), 98 | createRule = (value, test, params, enabled) => ({ 99 | value, 100 | test, 101 | params, 102 | enabled 103 | }), 104 | validateToArray = (rule, value) => (val, context) => { 105 | const ruleParams = getRuleParams(value, val, rule.params, context) 106 | if (!isEnabled(ruleParams, rule.enabled)) return [] 107 | return map(r => r.value, validateItem([ruleParams, rule.test])) 108 | }, 109 | runRule = (rule, value, mapFunction = validateToArray) => 110 | traverse(rule.value, value, mapFunction(rule, value)), 111 | runRaw = (rules, value) => 112 | map(r => runRule(r, value, validateToArray), rules), 113 | run = (rules, inValue) => { 114 | let res = {} 115 | const mapFunction = (rule, value) => (val, context) => { 116 | const ruleParams = getRuleParams(value, val, rule.params, context), 117 | resPath = 118 | (rule.to && getContextPath(rule.to, context.indexes)) || context.goal 119 | if (!isEnabled(ruleParams, rule.enabled)) return [] 120 | // eslint-disable-next-line one-var 121 | const errors = map(r => r.value, validateItem([ruleParams, rule.test])) 122 | if (!isEmpty(errors)) res = set(resPath, errors, res) 123 | return null 124 | } 125 | forEach(r => runRule(r, inValue, mapFunction), rules) 126 | return res 127 | }, 128 | makeListConfig = map(normalizeRule), 129 | makeObjectConfig = compose(makeListConfig, map(createRule), toPairs), 130 | makeConfig = (config) => { 131 | if (isArray(config)) return makeListConfig(config) 132 | if (isObject(config)) return makeObjectConfig(config) 133 | return config 134 | } 135 | 136 | export default runRule 137 | export { runRule, normalizeRule, makeConfig, runRaw, run } 138 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { getPath } from 'q3000' // eslint-disable-line 2 | import { isArray, map, all } from 'lodash/fp' 3 | 4 | const allAreArrays = all(isArray), 5 | arrayOfArrays = value => isArray(value) && allAreArrays(value), 6 | concat = (a = [], b = []) => Array.prototype.concat(a, b), 7 | pathValReg = /\{([^}]+)?}/g, 8 | until = (pred, transform, initial) => { 9 | let res = initial 10 | while (!pred(res)) res = transform(res) 11 | return res 12 | }, 13 | getContextPath = (inPath, contextIndexes = {}) => { 14 | if (!inPath) return inPath 15 | return map((p) => { 16 | let match = true, 17 | index = 0, 18 | result = '' 19 | const check = () => !match, 20 | append = (val) => { 21 | match = pathValReg.exec(p) 22 | if (!match) return val 23 | const variable = contextIndexes[match[1]], 24 | res = val + p.slice(index, match.index) + variable 25 | index = match.index + match[0].length 26 | return res 27 | } 28 | result = until(check, append, '') + p.substr(index, p.length - index) 29 | return result 30 | }, getPath(inPath)) 31 | } 32 | 33 | export { concat, arrayOfArrays, getContextPath } 34 | -------------------------------------------------------------------------------- /src/validation.js: -------------------------------------------------------------------------------- 1 | import { 2 | map, 3 | compose, 4 | filter, 5 | identity, 6 | isFunction, 7 | isBoolean 8 | } from 'lodash/fp' 9 | import { isExisty } from './assert' 10 | 11 | const Success = value => ({ value, type: 'Success' }), 12 | Failure = value => ({ value, type: 'Failure' }), 13 | matchWith = rule => v => rule[v.type](v.value), 14 | empty = () => null, 15 | createRule = (rule) => { 16 | const [test, message] = rule 17 | return (...rest) => { 18 | let resMessage = message 19 | const result = test(...rest) 20 | if (!isBoolean(result) && isExisty(result)) resMessage = result 21 | if (result === true) return Success(rest[0]) 22 | if (isFunction(resMessage)) return Failure(resMessage(...rest)) 23 | return Failure(resMessage) 24 | } 25 | }, 26 | createRules = map(createRule), 27 | runRules = ([input, test]) => map(rule => rule(...input), createRules(test)), 28 | getError = matchWith({ Success: empty, Failure: identity }), 29 | getSuccess = matchWith({ Success: identity, Failure: empty }), 30 | filterErrors = filter(getError), 31 | filterSuccess = filter(getSuccess), 32 | validateItem = compose(filterErrors, runRules), 33 | validate = map(validateItem) 34 | 35 | export { 36 | createRule, 37 | createRules, 38 | runRules, 39 | filterErrors, 40 | filterSuccess, 41 | validateItem, 42 | validate 43 | } 44 | -------------------------------------------------------------------------------- /test/rule.spec.js: -------------------------------------------------------------------------------- 1 | import { map, cloneDeep } from 'lodash/fp' 2 | import { runRule, normalizeRule, run, makeConfig } from '../src/rule' 3 | import { config, value } from './rule/test-config' 4 | 5 | const runNormalizedRule = (rule, val) => runRule(normalizeRule(rule), val) 6 | describe('rule', () => { 7 | it('normalizeRule is returning correct value', () => { 8 | const func = () => true, 9 | rule = { 10 | value: 'address.line1', 11 | params: [['address', 'line1'], func, 'boo.boo'], 12 | test: [func, 'error'], 13 | enabled: [func], 14 | random: 1, 15 | to: 'custom' 16 | } 17 | expect(normalizeRule(rule)).toEqual({ 18 | value: ['address', 'line1'], 19 | params: [['address', 'line1'], func, ['boo', 'boo']], 20 | test: [[func, 'error']], 21 | enabled: [func], 22 | random: 1, 23 | to: 'custom' 24 | }) 25 | }) 26 | 27 | it('runRule with all enabled is returning correct value', () => { 28 | const allEnabled = map(i => ({ ...i, enabled: [() => true] }), config) 29 | expect(runNormalizedRule(allEnabled[0], value)).toEqual([]) 30 | expect(runNormalizedRule(allEnabled[0], {})).toEqual(['empty']) 31 | expect(runNormalizedRule(allEnabled[1], value)).toEqual([]) 32 | expect(runNormalizedRule(allEnabled[1], {})).toEqual(['empty', 'min']) 33 | expect(runNormalizedRule(allEnabled[2], value)).toEqual([]) 34 | expect(runNormalizedRule(allEnabled[2], {})).toEqual(['empty']) 35 | expect(runNormalizedRule(allEnabled[3], value)).toEqual([]) 36 | expect(runNormalizedRule(allEnabled[3], {})).toEqual(['empty', 'custom']) 37 | expect(runNormalizedRule(allEnabled[4], value)).toEqual([]) 38 | expect(runNormalizedRule(allEnabled[4], {})).toEqual(['required']) 39 | expect(runNormalizedRule(allEnabled[5], value)).toEqual([[], []]) 40 | expect(runNormalizedRule(allEnabled[5], {})).toEqual(['empty']) 41 | expect( 42 | runRule(allEnabled[5], { team: [{ name: 'name' }, 1, null] }) 43 | ).toEqual([[], ['empty'], ['empty']]) 44 | expect(runNormalizedRule(allEnabled[6], value)).toEqual([[], []]) 45 | expect(runNormalizedRule(allEnabled[6], {})).toEqual(['empty']) 46 | expect(runNormalizedRule(allEnabled[7], value)).toEqual([[], []]) 47 | expect(runNormalizedRule(allEnabled[7], {})).toEqual(['required']) 48 | expect(runNormalizedRule(allEnabled[8], value)).toEqual([ 49 | [[], []], 50 | [[], []] 51 | ]) 52 | expect(runNormalizedRule(allEnabled[8], {})).toEqual(['empty', 'custom']) 53 | expect(runNormalizedRule(allEnabled[9], value)).toEqual([ 54 | [[], []], 55 | [[], []] 56 | ]) 57 | expect(runNormalizedRule(allEnabled[9], {})).toEqual(['empty', 'custom']) 58 | }) 59 | 60 | it('runRule is returning correct value', () => { 61 | expect(runNormalizedRule(config[0], value)).toEqual([]) 62 | expect(runNormalizedRule(config[0], {})).toEqual(['empty']) 63 | expect(runNormalizedRule(config[1], value)).toEqual([]) 64 | expect(runNormalizedRule(config[1], {})).toEqual(['empty', 'min']) 65 | expect(runNormalizedRule(config[2], value)).toEqual([]) 66 | expect(runNormalizedRule(config[2], {})).toEqual(['empty']) 67 | expect(runNormalizedRule(config[3], value)).toEqual([]) 68 | expect(runNormalizedRule(config[3], {})).toEqual([]) 69 | expect(runNormalizedRule(config[4], value)).toEqual([]) 70 | expect(runNormalizedRule(config[4], {})).toEqual(['required']) 71 | expect(runNormalizedRule(config[5], value)).toEqual([[], []]) 72 | expect(runNormalizedRule(config[5], {})).toEqual([]) 73 | expect( 74 | runNormalizedRule(config[5], { team: [{ name: 'name' }, 1, null] }) 75 | ).toEqual([[], ['empty'], ['empty']]) 76 | expect(runNormalizedRule(config[6], value)).toEqual([[], []]) 77 | expect(runNormalizedRule(config[6], {})).toEqual([]) 78 | expect(runNormalizedRule(config[7], value)).toEqual([[], []]) 79 | expect(runNormalizedRule(config[7], {})).toEqual([]) 80 | expect(runNormalizedRule(config[7], { team: [1, 2] })).toEqual([ 81 | ['required'], 82 | ['required'] 83 | ]) 84 | expect(runNormalizedRule(config[8], value)).toEqual([[[], []], [[], []]]) 85 | expect(runNormalizedRule(config[8], {})).toEqual([]) 86 | expect(runNormalizedRule(config[9], value)).toEqual([[[], []], [[], []]]) 87 | expect(runNormalizedRule(config[9], {})).toEqual([]) 88 | expect(runNormalizedRule(config[10], value)).toEqual([]) 89 | expect(runNormalizedRule(config[10], {})).toEqual(['no team']) 90 | }) 91 | 92 | it('runRules', () => { 93 | expect(run(makeConfig(config), value)).toEqual({}) 94 | expect(run(makeConfig(config), null)).toEqual({ 95 | address: ['empty'], 96 | age: ['empty', 'min'], 97 | hasTeam: ['required'], 98 | name: ['empty'], 99 | customTeam: ['no team'] 100 | }) 101 | expect(run(makeConfig(config), null)).toEqual({ 102 | address: ['empty'], 103 | age: ['empty', 'min'], 104 | hasTeam: ['required'], 105 | name: ['empty'], 106 | customTeam: ['no team'] 107 | }) 108 | const newValue = cloneDeep(value) 109 | newValue.team = [...newValue.team, { ...newValue.team[0], name: null }] 110 | expect(run(makeConfig(config), newValue)).toEqual({ 111 | team: [undefined, undefined, { customName: ['empty'], name: ['empty'] }] 112 | }) 113 | }) 114 | 115 | it('provides path to the processed value', () => { 116 | const 117 | successMap2 = { 118 | 'L.1.1': ['team', '0', 'address', '0'], 119 | 'L.2.1': ['team', '1', 'address', '0'], 120 | 'L.2.2': ['team', '1', 'address', '1'] 121 | }, 122 | successMap3 = { 123 | 'L.1.1': ['team', 0, 'address', 0, 'line1'], 124 | 'L.2.1': ['team', 1, 'address', 0, 'line1'], 125 | 'L.2.2': ['team', 1, 'address', 1, 'line1'] 126 | }, 127 | successMap3b = { 128 | 'L.1.1': { team: 0, address: 0 }, 129 | 'L.2.1': { team: 1, address: 0 }, 130 | 'L.2.2': { team: 1, address: 1 } 131 | }, 132 | rule = { 133 | // -1 134 | value: 'team.address.line1', 135 | params: [ 136 | 'team.{team}.address.{address}.line1', 137 | 'team.{team}.address.{address}.@', 138 | a => a 139 | ], 140 | test: [ 141 | [(v, ...params) => { 142 | const [p1, p2, p3] = params 143 | expect(successMap2[p1]).toEqual(p2) 144 | expect(successMap3[p1]).toEqual(p3.current) 145 | expect(successMap3b[p1]).toEqual(p3.indexes) 146 | return true 147 | }, 'ERR'] 148 | ] 149 | }, 150 | valForTest = { 151 | team: [ 152 | { 153 | address: [ 154 | { line1: 'L.1.1' } 155 | ] 156 | }, 157 | { 158 | address: [ 159 | { line1: 'L.2.1' }, 160 | { line1: 'L.2.2' } 161 | ] 162 | } 163 | ] 164 | } 165 | 166 | runNormalizedRule(rule, valForTest) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /test/rule/test-config.js: -------------------------------------------------------------------------------- 1 | import { notEmpty, required, min, maxLength, pattern } from './test-validation' 2 | 3 | const value = { 4 | name: 'Name', 5 | age: 20, 6 | address: { line1: 'Line 1' }, 7 | hasTeam: true, 8 | team: [ 9 | { 10 | name: 'Team member 1', 11 | position: { 12 | role: 'Manager' 13 | }, 14 | address: [ 15 | { line1: 'Line 1', postCode: { value: 1 } }, 16 | { line1: 'Line 2', postCode: { value: 2 } } 17 | ] 18 | }, 19 | { 20 | name: 'Team member 2', 21 | position: { 22 | role: 'Manager' 23 | }, 24 | address: [ 25 | { line1: 'Line 3', postCode: { value: 3 } }, 26 | { line1: 'Line 4', postCode: { value: 4 } } 27 | ] 28 | } 29 | ] 30 | }, 31 | config = [ 32 | { 33 | // 0 34 | value: 'name', 35 | params: ['name', 'age'], 36 | test: [ 37 | [notEmpty, 'empty'], 38 | [maxLength(50), () => 'max length'], 39 | [pattern(/\w*/gim), 'pattern'] 40 | ] 41 | }, 42 | { 43 | // 1 44 | value: 'age', 45 | test: [[notEmpty, 'empty'], [min(18), () => 'min']] 46 | }, 47 | { 48 | // 2 49 | value: 'address', 50 | test: [[required, 'empty']] 51 | }, 52 | { 53 | // 3 54 | value: 'address.line1', 55 | params: ['address', () => 'uk'], 56 | test: [ 57 | [notEmpty, 'empty'], 58 | [maxLength(50), () => 'max length'], 59 | [ 60 | (line1, addr, postCode) => 61 | addr && 62 | addr.line1 === 'Line 1' && 63 | line1 === 'Line 1' && 64 | postCode === 'uk', 65 | 'custom' 66 | ] 67 | ], 68 | enabled: [(line1, address) => !!address] 69 | }, 70 | { 71 | // 4 72 | value: 'hasTeam', 73 | test: [[required, 'required']] 74 | }, 75 | { 76 | // 5 77 | value: 'team.name', 78 | params: ['team'], 79 | test: [[notEmpty, 'empty']], 80 | enabled: [(name, team) => !!team] 81 | }, 82 | { 83 | // 6 84 | value: ['team', 'position', 'role'], 85 | params: ['team'], 86 | test: [[notEmpty, () => 'empty']], 87 | enabled: [(role, team) => !!team] 88 | }, 89 | { 90 | // 7 91 | value: ['team', 'address'], 92 | params: ['team'], 93 | test: [required, 'required'], 94 | enabled: [(address, team) => !!team] 95 | }, 96 | { 97 | // 8 98 | value: ['team', 'address', 'line1'], 99 | params: [ 100 | ['team', '{team}', 'address', '{address}', 'line1'], 101 | 'team.{team}.address.{address}' 102 | ], 103 | test: [ 104 | [notEmpty, 'empty'], 105 | [maxLength(50), () => 'max length'], 106 | [ 107 | (line1, lineAgain, addr) => 108 | line1 === lineAgain && 109 | addr && 110 | addr.line1.indexOf('Line') !== -1 && 111 | line1.indexOf('Line') !== -1, 112 | 'custom' 113 | ] 114 | ], 115 | enabled: [ 116 | (line1, lineAgain, addr) => !!addr, 117 | (line1, lineAgain, addr) => !!addr, 118 | () => true 119 | ] 120 | }, 121 | { 122 | // 9 123 | value: ['team', 'address', 'postCode', 'value'], 124 | params: [ 125 | ['team', '{team}', 'address', '{address}', 'line1'], 126 | 'team.{team}.address.{address}' 127 | ], 128 | test: [ 129 | [notEmpty, 'empty'], 130 | [maxLength(50), () => 'max length'], 131 | [ 132 | (postCode, line1, addr) => 133 | !!(addr && addr.line1 === line1 && postCode), 134 | 'custom' 135 | ] 136 | ], 137 | enabled: [(line1, address) => !!address, (line1, address) => !!address] 138 | }, 139 | { 140 | // 10 141 | value: 'team', 142 | to: 'customTeam', 143 | test: [[team => !!team, 'no team']] 144 | }, 145 | { 146 | // 11 147 | value: 'team.name', 148 | params: ['team'], 149 | test: [[notEmpty, 'empty']], 150 | to: 'team.{team}.customName', 151 | enabled: [(name, team) => !!team] 152 | } 153 | ] 154 | 155 | export { value, config } 156 | -------------------------------------------------------------------------------- /test/rule/test-validation.js: -------------------------------------------------------------------------------- 1 | import { toString, isNumber, isString, isArray } from 'lodash/fp' 2 | import { isExisty } from '../../src/assert' 3 | 4 | const notEmpty = val => !!val, 5 | pattern = patternVal => (val) => { 6 | patternVal.lastIndex = 0 // eslint-disable-line 7 | return patternVal.test(val) 8 | }, 9 | required = isExisty, 10 | length = a => a.length, 11 | minLength = len => val => length(toString(val)) >= len, 12 | maxLength = len => val => length(toString(val)) <= len, 13 | min = minVal => val => parseFloat(val) >= minVal, 14 | max = maxVal => val => parseFloat(val) <= maxVal 15 | 16 | export { 17 | notEmpty, 18 | isNumber, 19 | isString, 20 | isArray, 21 | pattern, 22 | required, 23 | minLength, 24 | maxLength, 25 | min, 26 | max 27 | } 28 | -------------------------------------------------------------------------------- /test/validation.spec.js: -------------------------------------------------------------------------------- 1 | import { map } from 'lodash/fp' 2 | import { 3 | createRule, 4 | createRules, 5 | runRules, 6 | validate, 7 | filterErrors 8 | } from '../src/validation' 9 | 10 | describe('validation', () => { 11 | it('createRule is returning correct value', () => { 12 | expect(createRule([() => true, 'error'])(1).value).toBe(1) 13 | expect(createRule([() => false, 'error'])(1).value).toBe('error') 14 | }) 15 | 16 | it('createRules is returning correct value', () => { 17 | expect( 18 | createRules([[() => true, 'error'], [() => false, 'error']]) 19 | ).toBeInstanceOf(Array) 20 | }) 21 | 22 | it('runRules returns correct value', () => { 23 | const result = runRules([ 24 | [1], 25 | [[() => true, 'error'], [() => false, 'error']] 26 | ]) 27 | expect(result).toBeInstanceOf(Array) 28 | expect(map(r => r.value, result)).toEqual([1, 'error']) 29 | }) 30 | 31 | it('runRules returns correct value', () => { 32 | const result = runRules([[1], [[() => ({ error: 'error' })]]]) 33 | expect(result).toBeInstanceOf(Array) 34 | expect(map(r => r.value, result)).toEqual([{ error: 'error' }]) 35 | }) 36 | 37 | it('filterErrors returns correct value', () => { 38 | let result = runRules([ 39 | [1], 40 | [ 41 | [() => true, 'error1'], 42 | [() => false, 'error2'], 43 | [() => true, 'error3'], 44 | [() => false, 'error4'] 45 | ] 46 | ]) 47 | result = filterErrors(result) 48 | expect(map(r => r.value, result)).toEqual(['error2', 'error4']) 49 | }) 50 | 51 | it('validate returns correct value', () => { 52 | const result = validate([ 53 | [[1], [[() => true, 'error'], [() => false, 'error']]] 54 | ]) 55 | expect(result).toBeInstanceOf(Array) 56 | expect(map(v => map(r => r.value, v), result)).toEqual([['error']]) 57 | }) 58 | }) 59 | --------------------------------------------------------------------------------