/** * Context * @typedef {object} Context * @property {boolean} isError * @property {string} errorMessage * @property {number} index * @property {any} result * @property {any} srcObj * @property {string} path * */ /** * @param {string} path * @param {object} obj * @returns {object} */ const getValueAt = (path, obj)=>{ if(path === '') return obj; const keys = path.split('.').filter(e=>e !== ''); return keys.reduce((acc, key)=> { return acc[key]; }, obj); } /** * * @param {string} path * @returns {string} */ const printPath = (path)=>{ if(path === '') return ''; return `at path ${path.slice(1)}` } class Rule { /** * * @param {(c:Context)=>Context} ruleValidationFn */ constructor(ruleValidationFn) { this.ruleValidationFn = ruleValidationFn; } /** * * @param {(any)=>any} mapFn * @returns {Rule} */ map(mapFn) { return new Rule((ctx) => { const nextCtx = this.ruleValidationFn(ctx); if (nextCtx.isError) { return { ...ctx, isError: true, errorMessage: nextCtx.errorMessage, }; }; return { ...nextCtx, result: mapFn(nextCtx.result), } }); } mapError(mapFn) { return new Rule((ctx) => { const nextCtx = this.ruleValidationFn(ctx); if (nextCtx.isError) { return { ...ctx, isError: true, errorMessage: mapFn(nextCtx.errorMessage, nextCtx.path, nextCtx.index) }; }; return { ...nextCtx, } }); } /** * * @param {(any)=>Context} chainFn * @returns {Rule} */ chain(chainFn) { return new Rule((ctx) => { let nextCtx = this.ruleValidationFn(ctx); let nextRule = chainFn(nextCtx.result); nextCtx = nextRule.run(nextCtx); return nextCtx; }); } /** * * @param {Rule} rule * @returns {Rule} */ is(rule){ return this.chain(()=>rule); } /** * * @param {Context} ctx * @returns {Context} */ run(ctx) { if (ctx.isError) return ctx; return this.ruleValidationFn(ctx); } test(obj){ return this.run({ srcObj: obj, index: 0, path:"", isError: false, errorMessage: "", }) } } /** * * @param {string} str * @returns {Rule} */ const str = (str) => new Rule((ctx) => { const value = getValueAt(ctx.path, ctx.srcObj); if (typeof value !== 'string') { return { ...ctx, isError: true, errorMessage: `Expected string but got ${typeof value} ${printPath(ctx.path)}`, } } if (value.slice(ctx.index).startsWith(str)) { return { ...ctx, index: ctx.index + str.length, result: str, } }; return { ...ctx, isError: true, errorMessage: `Expected ${str} but got ${value.slice(ctx.index, str.length)} ${printPath(ctx.path)}`, } }); /** * * @param {RegExp} reg * @returns {Rule} */ const regex = (reg) => new Rule((ctx) => { const value = getValueAt(ctx.path, ctx.srcObj); if (typeof value !== 'string') { return { ...ctx, isError: true, errorMessage: `Expected string but got ${typeof value} ${printPath(ctx.path)}`, } } const match = value.slice(ctx.index).match(reg); if (match) { return { ...ctx, index: ctx.index + match[0].length, result: match[0], errorMessage: '', } } let pretty = value.slice(ctx.index, 20); pretty = (value.length > 20) ? (pretty + '...') : pretty; return { ...ctx, isError: true, errorMessage: `${pretty} did not match ${reg} ${printPath(ctx.path)}`, } }); /** * * @param {...Rule} rules * @returns {Rule} */ const sequenceOf = (...rules) => new Rule((ctx) => { const results = []; let nextContext = ctx; for (rule of rules) { nextContext = rule.run(nextContext); if (!nextContext.isError) { results.push(nextContext.result); } else { break; } }; return { ...nextContext, result: results, }; }); /** * * @param {...Rule} rules * @returns {Rule} */ const choice = (...rules) => new Rule((ctx) => { let errorMessages = []; for (let rule of rules) { const nextCtx = rule.run(ctx); if (!nextCtx.isError) { return nextCtx; } else { errorMessages.push(nextCtx.errorMessage); } } return { ...ctx, isError: true, errorMessage: ['No match found', ...errorMessages], } }); /** * * @param {Rule} rule * @returns {Rule} */ const many = (rule) => new Rule((ctx) => { const res = []; let nextCtx = ctx; while (!nextCtx.isError) { let s = rule.run(nextCtx); if (!s.isError) { res.push(s.result); nextCtx = s; } else { break; } } return { ...nextCtx, result: res, } }); /** * * @generator * @function GeneratorFn * @yields {Rule} * @returns {any} */ /** * * @param {GeneratorFn} generatorFn * @returns */ const context = (generatorFn) => { return new Rule((ctx) => ctx).chain(() => { const iterator = generatorFn(); const runStep = (nextValue) => { const response = iterator.next(nextValue); if (response.done) { return new Rule((ctx) => { return { ...ctx, result: response.value, }; }); } return response.value.chain(runStep); }; return runStep(); }); } /** * * @param {Rule} rule * @returns {Rule} */ const lookAhead = (rule) => new Rule((ctx) => { const va = rule.run(ctx); if (va.isError) { return { ...ctx, result: undefined, } } return { ...ctx, result: va.result, }; }); /** * * @param {string} key * @returns {Rule} */ const hasKey = (key)=>{ return new Rule((ctx)=>{ const value = getValueAt(ctx.path, ctx.srcObj); if(value !== Object(value)){ return { ...ctx, isError:true, errorMessage:`Expected object but found ${typeof value} ${printPath(ctx.path)}`, } } if(!value.hasOwnProperty(key)){ return { ...ctx, isError:true, errorMessage:`Expected key ${key} ${printPath(ctx.path)}`, } } return { ...ctx, index: 0, // we reset the index when we nest path: `${ctx.path}.${key}`, result: ctx.srcObj[key], } }); } /** * * @param {...Rule} rules * @returns {Rule} */ const and = (...rules) => { return new Rule((ctx)=>{ let nextCtx; const result = []; for(const rule of rules){ // we keep the same context for every rule nextCtx = rule.run(ctx); if(nextCtx.isError){ break; } result.push(nextCtx.result); } return { ...ctx, isError: nextCtx.isError, errorMessage: nextCtx.errorMessage, result, } }); } const isBoolean = new Rule((ctx)=>{ const value = getValueAt(ctx.path, ctx.srcObj); if(Boolean(value) === value){ return { ...ctx, result: value, } } return { ...ctx, isError:true, errorMessage:`Expected boolean but found ${typeof value} ${printPath(ctx.path)}` } }); const isNumber = new Rule((ctx)=>{ const value = getValueAt(ctx.path, ctx.srcObj); if(Number(value) === value){ return { ...ctx, result: value, } } return { ...ctx, isError:true, errorMessage:`Expected number but found ${typeof value} ${printPath(ctx.path)}` } }); const isArray = new Rule((ctx)=>{ const value = getValueAt(ctx.path, ctx.srcObj); if(Array.isArray(value)){ return { ...ctx, result: value, } } return { ...ctx, isError:true, errorMessage:`Expected array but found ${typeof value} ${printPath(ctx.path)}` } }); const isObject = new Rule((ctx)=>{ const value = getValueAt(ctx.path, ctx.srcObj); if(value !== Object(value)){ return { ...ctx, isError:true, errorMessage:`Expected object but found ${typeof value} ${printPath(ctx.path)}`, } } return { ...ctx, result:value, } }); /** * * @param {Rule} rule * @returns {Rule} */ const arraySome = (rule)=> isArray.chain((arr)=>new Rule((ctx)=>{ const ret = arr.findIndex((_, i)=>{ return rule.run({ ...ctx, index:0, path: `${ctx.path}.${i}`, }).isError === false; }); if(ret !== -1){ return { ...ctx, result: arr[ret], } } return { ...ctx, isError:true, errorMessage: `No value matching the rule was found ${printPath(ctx.path)}`, } })); /** * * @param {Rule} rule * @returns {Rule} */ const arrayOf = (rule)=> isArray.chain((arr)=>new Rule((ctx)=>{ let message = ''; const ret = arr.findIndex((_, i)=>{ const res = rule.run({ ...ctx, index:0, path: `${ctx.path}.${i}`, }); message = res.errorMessage; return res.isError !== false; }); if(ret === -1){ return { ...ctx, result: arr, } } return { ...ctx, isError:true, errorMessage: `The value ${printPath(ctx.path)} didn't match the rule message: ${message}`, } })); /** * * @param {string} errorMessage * @param {(a:number, b?:number)=>boolean} op * @returns {(n?:number)=>Rule} */ const NumberCompare = (op, errorMessage) => (n) => isNumber.chain((value)=>{ return new Rule((ctx)=> { if(op(value, n)){ return { ...ctx, result: value, } } else { return { ...ctx, isError:true, errorMessage:`Number ${value} should be ${errorMessage}${n ? ` ${n}`:''} ${printPath(ctx.path)}`, } } }); }); const gt = NumberCompare((a, b)=> a > b, 'greater than'); const lt = NumberCompare((a, b)=> a < b, 'less than'); const gte = NumberCompare((a, b)=> a >= b, 'greater than or equal to'); const lte = NumberCompare((a, b)=> a <= b, 'less than or equal to'); const neq = NumberCompare((a, b)=> a !== b, 'different than'); const eq = NumberCompare((a, b)=> a === b, 'equal to'); const isInteger = NumberCompare((a)=> { return Number.isInteger(a) }, 'be an integer')(); const printAdditionalKeys = (arr)=>{ if(arr.length === 0) return ''; return `The keys: ${arr.join(', ')} are forbiden in the object.`; } const printMissingKeys = (arr)=>{ if(arr.length === 0) return ''; return `The keys: ${arr.join(',')} are missing from the object. `; } /** * * @param {...string} keys * @returns {Rule} */ const onlyKeys = (...targetKeys) => isObject.chain((obj)=>new Rule((ctx)=>{ const keys = Object.keys(obj); if(targetKeys.length !== keys.length || keys.find((k, i)=> k !==targetKeys[i])){ const missingKeys = targetKeys.filter(k => !keys.includes(k)); const additionalKeys = keys.filter(k => !targetKeys.includes(k)); return { ...ctx, isError:true, errorMessage: `${printMissingKeys(missingKeys)}${printAdditionalKeys(additionalKeys)} ${printPath(ctx.path)}` } } return { ...ctx, } })) ///// let test; const arrayWith = arraySome; const userName = regex(/^[\p{L} ]+$/ui); const age = choice(gt(10), isInteger).mapError((e)=> e); const isVip = isBoolean; const user = and( hasKey('name').is(userName), hasKey('age').is(age), hasKey('vip').is(isVip), hasKey('firends').is(arrayOf(userName)), onlyKeys( "name", "vip", "age", "someOtherKey", "firends" ), ) test = user.test({ 'name':"Alice Doe", "someOtherKey": "123", "vip": false, 'age': .1, "firends":["Joe"], }); console.log(test);