UNPKG

specma

Version:

Simple, reusable and composable validation

656 lines (608 loc) 20.7 kB
import merge from 'deepmerge'; /* Return a string describing the type of the argument. * More precise than the native typeof operator. * https://stackoverflow.com/a/28475765 * typeOf(); //undefined * typeOf(null); //null * typeOf(NaN); //number * typeOf(5); //number * typeOf({}); //object * typeOf([]); //array * typeOf(''); //string * typeOf(function () {}); //function * typeOf(/a/) //regexp * typeOf(new Date()) //date * typeOf(new Error) //error * typeOf(Promise.resolve()) //promise * typeOf(function *() {}) //generatorfunction * typeOf(new WeakMap()) //weakmap * typeOf(new Map()) //map */ const typeOf = obj => ({}).toString.call(obj).split(" ")[1].slice(0, -1).toLowerCase(); const isType = type => x => typeOf(x) === type; const isArr = isType("array"); const isColl = x => ["array", "map", "object"].includes(typeOf(x)); const isFunc = x => typeof x === "function"; const isNil = x => [null, undefined].includes(x); const isNum = isType("number"); const isPromise = x => x && isFunc(x.then); const isSpec = x => isFunc(x) || isColl(x); const polymorph = (implementations, posOfColl = 1) => (...args) => { const type = typeOf(args[posOfColl - 1]); const fn = implementations[type] || implementations["_"]; if (!fn) throw new TypeError(`Not implemented for type '${type}'`); return fn(...args); }; const entries = polymorph({ array: arr => arr.map((v, i) => [i, v]), map: map => Array.from(map.entries()), object: obj => Object.entries(obj), _: () => [] }); const fromMap = polymorph({ array: map => { const indices = Array.from(map.keys()).filter(isNum); const maxIndex = Math.max(...indices); return Array.from({ length: maxIndex + 1 }, (_, i) => map.get(i)); }, map: map => new Map(map), object: map => Object.fromEntries(Array.from(map.entries()).filter(([key]) => key !== undefined)) }, 2); const get = polymorph({ array: (index, arr) => arr[index], map: (key, map) => map.get(key), object: (key, obj) => obj[key], _: () => undefined }, 2); const set = polymorph({ array: (index, value, arr) => Object.assign([], arr, { [index]: value }), map: (key, value, map) => map.set(key, value), object: (key, value, obj) => Object.assign({}, obj, { [key]: value }), _: (key, value, x) => x[key] = value }, 3); function getPath(path = [], value) { return path.reduce((parent, key) => get(key, parent), value); } const keys = polymorph({ array: arr => arr.map((v, i) => i), map: map => Array.from(map.keys()), object: obj => Object.keys(obj), _: () => [] }); function mergePaths(...paths) { const path = paths.reduce((acc, path) => { if (path === undefined) return acc; if (isArr(path)) return [...acc, ...path]; return [...acc, path]; }, []).filter(key => key !== undefined); return path.length > 0 ? path : undefined; } function asKey(key) { if (!isColl(key)) return key; return JSON.stringify(key); } const OPTIONAL = Symbol("OPTIONAL"); const PRED = Symbol("PRED"); const RESULT = Symbol("RESULT"); const VALID = { valid: true, promise: Promise.resolve({ valid: true }) }; function toSpec(x) { if (isFunc(x)) return new Map([[undefined, x]]); if (isColl(x)) { return new Map([[undefined, x[PRED]], ...specEntries(x)].filter(([, y]) => y !== undefined).map(([key, y]) => [key, y])); } return x; } const fromSpec = polymorph({ array: spec => { const indices = Array.from(spec.keys()).filter(isNum); const maxIndex = Math.max(...indices); const array = Array.from({ length: maxIndex + 1 }, (_, i) => spec.get(i)); array["..."] = spec.get("..."); array[PRED] = spec.get(undefined); return array; }, function: spec => spec.get(undefined), map: spec => { const map = new Map(Array.from(spec.entries()).filter(([key]) => key !== undefined)); map[PRED] = spec.get(undefined); return map; }, object: spec => { const obj = Object.fromEntries(Array.from(spec.entries()).filter(([key]) => key !== undefined)); obj[PRED] = spec.get(undefined); return obj; } }, 2); const specEntries = polymorph({ array: arr => [...arr.map((v, i) => [i, v]), ["...", arr["..."]]], map: map => Array.from(map.entries()), object: obj => Object.entries(obj) }); function opt(selection = {}) { selection[OPTIONAL] = true; return selection; } function isOpt(selection) { return !selection || !!selection[OPTIONAL]; } /* Check if result has been tagged as one. */ function isResult(res) { return isColl(res) && !!res[RESULT]; } /* Throw promised result if it is invalid. */ function passFailAsync(result) { return result.promise.then(promisedRes => { if (promisedRes.valid === true) return promisedRes; throw promisedRes; }); } /* Race for first invalid result by capturing it in a `catch` clause. * If all promises resolve without triggering the catch, * it means that all are valid. */ function resultsRace(results) { return Promise.all(results.map(passFailAsync)).then(() => VALID).catch(result => result); } /* Tag a result as one, so that a predicate function * could return one without it being considered a failed * predicate answer. */ function tagAsResult(result) { result[RESULT] = true; return result; } function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } const defaultMessages = { isInvalid: "is invalid", isRequired: "is required" }; function getMessage(key, messages = defaultMessages) { return messages[key] || defaultMessages[key]; } function setMessages(messages = {}) { Object.assign(defaultMessages, messages); } /* Return a result object of shape * { * valid: true|false|null * promise, * reason, * value, * failedValue, * failedPath, * } */ function _validate(specable, value, { getFrom: argGetFrom, key, path, rootValue } = {}) { const enhanceArgs = { path, value }; /* Always valid if not a usable spec */ if (!isSpec(specable)) return enhanceResult(VALID, enhanceArgs); /* Immediately validate collection type */ if (isColl(specable)) { const collType = typeOf(specable); if (typeOf(value) !== collType) return enhanceResult({ valid: false, reason: `must be of type ${collType}` }, enhanceArgs); } /* Transform into a Map spec */ const spec = toSpec(specable); /* Create value navigation function to act as variable context */ const getFrom = argGetFrom || function (relPath) { return getFromValue(relPath, path, rootValue); }; /* Check own predicate spec */ const pred = spec.get(undefined); const predResult = validatePred(pred, value, getFrom, { key }); if (!isColl(specable) || predResult.valid === false) return enhanceResult(predResult, enhanceArgs); /* If spec is a collection one, validate entries, combined with own predicate result */ const results = entries(value).reduce((acc, [subKey, subVal]) => { const subSpec = spec.get(subKey) || spec.get("..."); /* Undefined value is not considered. `required` option will validate this. * Unspeced entry is not considered. */ if (subVal === undefined || !isSpec(subSpec)) return acc; const result = _validate(subSpec, subVal, { key: subKey, path: mergePaths(path, subKey), rootValue: rootValue || value }); return [...acc, result]; }, [predResult]); return enhanceResult(interpretCollValidation(results), enhanceArgs); } function validatePred(pred, value, getFrom, options) { const ans = !pred || failSafeCheck(pred, value, getFrom, options); return interpretPredAnswer(ans); } /* Check a value against a predicate. * If an error occurs during validation, returns false without throwing. */ function failSafeCheck(pred, ...args) { const defaultReason = pred.name ? `failed '${pred.name}'` : getMessage("isInvalid"); try { return pred(...args) || defaultReason; } catch (err) { /* eslint-disable no-console */ if (console && console.error) { console.error(`Specma: Failed '${pred.name}' pred because of runtime error:`, err.message); } /* eslint-enable no-console */ return defaultReason; } } function enhanceResult(res, { path, value }) { const enhanced = res.valid === false ? _extends({}, res, { failedPath: res.failedPath || path, failedValue: res.failedValue || value, value }) : res; const promise = res.promise ? res.promise.then(promised => enhanceResult(promised, { path, value })) : Promise.resolve(enhanced); return _extends({}, enhanced, { promise }); } function interpretPredAnswer(ans) { /* If answer is itself already a result (tagged as one), return it. */ if (isResult(ans)) return ans; if (ans === true) return VALID; if (isPromise(ans)) return { valid: null, promise: ans.then(promisedAns => interpretPredAnswer(promisedAns)) }; return { valid: false, reason: ans || getMessage("isInvalid") }; } function interpretCollValidation(results = []) { /* Any is invalid */ const firstInvalid = results.find(({ valid }) => ![null, true].includes(valid)); if (firstInvalid) return firstInvalid; /* All valid synchronously */ if (results.every(({ valid }) => valid === true)) return VALID; /* Some promises */ const unresolvedResults = results.filter(({ valid }) => valid === null); return { valid: null, promise: resultsRace(unresolvedResults).then(promisedResult => { return interpretCollValidation([promisedResult]); }) }; } /* Given a value and a current path, return the sub value * at a path relative to current one. */ function getFromValue(relPath, currPath = [], value) { const newPath = relPath.split("/").reduce((acc, move) => { if ([null, undefined, "", "."].includes(move)) return acc; if (move.startsWith("..")) return acc.slice(0, -1); const index = parseInt(move, 10); return [...acc, isNaN(index) ? move : index]; }, currPath); return getPath(newPath, value); } function getPred(spec) { if (isFunc(spec)) return spec; return spec && spec[PRED]; } /* Combine multiple predicate specs into a single function, * racing for first invalid result when async. */ function combinePreds(...preds) { const realPreds = preds.map(pred => isFunc(pred) ? pred : () => true); if (realPreds.length <= 1) return realPreds[0]; function combinedPred(...args) { const results = realPreds.map(pred => validatePred(pred, ...args)); /* Any is invalid */ const firstInvalid = results.find(({ valid }) => ![null, true].includes(valid)); if (firstInvalid) { if (isResult(firstInvalid)) return firstInvalid; return firstInvalid.reason; } /* All valid synchronously */ if (results.every(({ valid }) => valid === true)) return true; /* Some promises */ const unresolvedResults = results.filter(({ valid }) => valid === null); return resultsRace(unresolvedResults).then(res => { if (isResult(res)) return res; return res.valid === true || res.reason; }); } /* Set the arity of the function based on the max arity of predicates. */ const maxLength = realPreds.reduce((acc, pred) => Math.max(acc, pred.length), 0); Object.defineProperty(combinedPred, "length", { value: maxLength }); return combinedPred; } function and(...specables) { const firstColl = specables.find(isColl); if (!firstColl) return combinePreds(...specables); const spec = fromSpec(mergeSpecs(...specables), firstColl); return specables.every(isOpt) ? opt(spec) : spec; } function mergeSpecs(...specables) { const specs = specables.map(toSpec); const allKeys = new Set(specs.flatMap(spec => Array.from(spec.keys()))); return new Map(Array.from(allKeys.values()).map(key => { const keySubSpecs = specs.filter(spec => spec.has(key)).map(spec => spec.get(key)); return [key, and(...keySubSpecs)]; })); } function spread(spec, coll = []) { if (!isColl(coll)) throw new TypeError("Spread (...) can only be applied on a collection spec"); return and(coll, new Map([["...", spec]])); } function getSpread(coll) { return get("...", coll); } function findMissingPath(selection, coll, currKey) { let reqEntries = entries(selection).filter(([k, v]) => k !== "..." && !!v); /* Append all collection keys as requirement entries if a spread is defined. */ const spreadSelection = getSpread(selection); if (spreadSelection && !(isOpt(spreadSelection) && coll === undefined)) { reqEntries = [...reqEntries, ...keys(coll).map(k => [k, spreadSelection])]; } /* Get the top level required keys */ const reqKeys = reqEntries.reduce((acc, [k, v]) => { if (isColl(v) && isOpt(v)) return acc; return [...acc, k]; }, []); const missingKey = reqKeys.find(k => isNil(get(k, coll))); if (missingKey !== undefined) return mergePaths(asKey(currKey), missingKey); /* Drill down recursively into sub paths */ const missingSubKey = reqEntries.reduce((acc, [k, subReq]) => { if (acc !== undefined) return acc; if (!isColl(subReq)) return undefined; const optional = isOpt(subReq); const subValue = get(k, coll); if (isNil(subValue)) return optional ? undefined : k; return findMissingPath(subReq, subValue, k); }, undefined); if (missingSubKey !== undefined) return mergePaths(asKey(currKey), missingSubKey); return undefined; } /* Deep select value from selection. Collection spec can be used as selection. * A branch will be selected if its selection value is thruthy. */ function select(selection, value) { if (!(isColl(selection) && isColl(value))) return value; const explicitSelectionMap = new Map(entries(selection).filter(([k]) => k !== "...")); const spreadSelection = getSpread(selection); if (!spreadSelection && explicitSelectionMap.size <= 0) return value; return fromMap(new Map(Array.from(entries(value)).filter(([key]) => explicitSelectionMap.has(key) ? explicitSelectionMap.get(key) : !!spreadSelection).map(([key, val]) => [key, select(explicitSelectionMap.get(key) || spreadSelection, val)]).filter(([, val]) => val !== undefined)), value); } function createSelection({ selection, spec, required }) { if (!selection) return undefined; if (isColl(selection)) return selection; return mergeColls(spec, required); } function mergeColls(...arr) { const colls = arr.filter(isColl); if (colls.length < 2) return colls[0]; const merged = colls.reduce((a, b) => merge(a, b, { arrayMerge: combineArrays })); return colls.every(isOpt) ? opt(merged) : merged; } function combineArrays(target, source, options) { const destination = target.slice(); source.forEach((item, index) => { if (destination[index] === undefined) { destination[index] = options.cloneUnlessOtherwiseSpecified(item, options); } else if (options.isMergeableObject(item)) { destination[index] = merge(target[index], item, options); } else if (target.indexOf(item) === -1) { destination.push(item); } }); destination["..."] = mergeColls(target["..."], source["..."]); return destination; } /* Validate a value against a spec and return a result of shape * { * valid: true|false|null * promise, * reason, * value, * failedValue, * failedPath, * }. * Built-in messages can be overriden manually. * Required fields and value selection can be specified. */ function validate(specable, value, { messages = defaultMessages, required, selection: sel = false } = {}, cb = () => {}) { const selection = createSelection({ selection: sel, spec: specable, required }); const prunedValue = selection ? select(selection, value) : value; let enhancedSpecable = specable; if (isColl(specable) && !!required) { enhancedSpecable = and(function requireSpec(value) { const missingPath = findMissingPath(required, value); if (missingPath === undefined) return true; const res = { valid: false, reason: getMessage("isRequired", messages), failedPath: missingPath }; return tagAsResult(res); }, specable); } const result = _validate(enhancedSpecable, prunedValue, { rootValue: prunedValue }); const enhanced = _extends({}, result, { value: prunedValue }); cb(enhanced); if (enhanced.valid === null) enhanced.promise.then(cb); return enhanced; } function validateAsync(...args) { return validate(...args).promise; } /* Return `true` if valid, error reason if invalid * or a promise that will return those. */ function check(spec, value, options, cb = () => {}) { return interpretCheck(validate(spec, value, options, result => cb(interpretCheck(result)))); } async function checkAsync(...args) { return Promise.resolve(check(...args)); } function interpretCheck(result) { if (result.valid === null) return result.promise.then(interpretCheck); if (result.valid === true) return true; return enhanceReason(result); } function enhanceReason({ reason, failedPath = [] }) { const key = failedPath.join("."); const stringified = typeof reason === "object" ? JSON.stringify(reason) : reason.toString(); const message = key ? `'${key}' ${stringified}` : stringified; return message; } /* Return value if valid, throw error if invalid * or a promise that will do so. */ function conform(spec, value, options, cb = () => {}) { return interpretConform(validate(spec, value, options, response => cb(interpretConform(response)))); } function conformAsync(...args) { return Promise.resolve(conform(...args)); } function interpretConform(result) { if (result.valid === true) return result.value; if (result.valid === null) return result.promise.then(promised => interpretConform(promised)); const error = new Error(enhanceReason(result)); error.details = result; throw error; } /* Returns `true`, `false` or a promise that resolves to these values. */ function isValid(spec, value, options, cb = () => {}) { return interpretIsValid(check(spec, value, options, response => cb(interpretIsValid(response)))); } function isValidAsync(...args) { return Promise.resolve(isValid(...args)); } function interpretIsValid(ans) { if (ans === true) return true; if (isPromise(ans)) return ans.then(interpretIsValid); return false; } /* Create a predicate that will check a value's key * instead of the value itself. */ function key(keySpec) { return (value, getFrom, options = {}) => { const result = validatePred(keySpec, options.key, getFrom, options); if (result.valid === false) result.failedValue = options.key; return tagAsResult(_extends({}, result, { keyValidation: true })); }; } function or(...args) { const specs = args.filter(isSpec); return function _or(value, getFrom) { const results = specs.map(spec => _validate(spec, value, { getFrom })); return interpretResults(results); }; } function interpretResults(results) { /* If any response is already valid, `or` is valid. */ if (results.some(({ valid }) => valid === true)) return true; const pendingResults = results.filter(({ valid }) => valid === null); /* If no pending results, return the reason of the first invalid reason. * If no invalid result is found, `or` is valid. */ if (pendingResults.length <= 0) { /* If any answer is true, `or` is true. */ if (results.some(({ valid }) => valid === true)) return true; /* Otherwise, return the first failed result */ const lastInvalid = results.find(({ valid }) => valid === false); return tagAsResult(lastInvalid); } /* If there is any pending result, return a global promise * that will resolve at the first valid answer * or when all promises have resolved. */ return raceValidResult(pendingResults); } function failIfValidAsync(result) { return result.promise.then(promisedRes => { if (promisedRes.valid !== true) return promisedRes; throw promisedRes; }); } function raceValidResult(results) { return Promise.all(results.map(failIfValidAsync)).then(interpretResults).catch(() => true); } const util = { entries, fromMap, get, getPath, keys, mergePaths, set, typeOf }; export { and, check, checkAsync, conform, conformAsync, createSelection, getMessage, getPred, getSpread, isOpt, isValid, isValidAsync, key, opt, or, select, setMessages, spread, util, validate, validateAsync, validatePred };