UNPKG

specma

Version:

Simple, reusable and composable validation

963 lines (850 loc) 28.1 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('deepmerge')) : typeof define === 'function' && define.amd ? define(['exports', 'deepmerge'], factory) : (global = global || self, factory(global.specma = {}, global.deepmerge)); })(this, (function (exports, merge) { function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var merge__default = /*#__PURE__*/_interopDefaultLegacy(merge); /* 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 */ var typeOf = function typeOf(obj) { return {}.toString.call(obj).split(" ")[1].slice(0, -1).toLowerCase(); }; var isType = function isType(type) { return function (x) { return typeOf(x) === type; }; }; var isArr = isType("array"); var isColl = function isColl(x) { return ["array", "map", "object"].includes(typeOf(x)); }; var isFunc = function isFunc(x) { return typeof x === "function"; }; var isNil = function isNil(x) { return [null, undefined].includes(x); }; var isNum = isType("number"); var isPromise = function isPromise(x) { return x && isFunc(x.then); }; var isSpec = function isSpec(x) { return isFunc(x) || isColl(x); }; var polymorph = function polymorph(implementations, posOfColl) { if (posOfColl === void 0) { posOfColl = 1; } return function () { var args = [].slice.call(arguments); var type = typeOf(args[posOfColl - 1]); var fn = implementations[type] || implementations["_"]; if (!fn) throw new TypeError("Not implemented for type '" + type + "'"); return fn.apply(void 0, args); }; }; var entries = polymorph({ array: function array(arr) { return arr.map(function (v, i) { return [i, v]; }); }, map: function map(_map) { return Array.from(_map.entries()); }, object: function object(obj) { return Object.entries(obj); }, _: function _() { return []; } }); var fromMap = polymorph({ array: function array(map) { var indices = Array.from(map.keys()).filter(isNum); var maxIndex = Math.max.apply(Math, indices); return Array.from({ length: maxIndex + 1 }, function (_, i) { return map.get(i); }); }, map: function map(_map2) { return new Map(_map2); }, object: function object(map) { return Object.fromEntries(Array.from(map.entries()).filter(function (_ref) { var key = _ref[0]; return key !== undefined; })); } }, 2); var get = polymorph({ array: function array(index, arr) { return arr[index]; }, map: function map(key, _map3) { return _map3.get(key); }, object: function object(key, obj) { return obj[key]; }, _: function _() { return undefined; } }, 2); var set = polymorph({ array: function array(index, value, arr) { var _Object$assign; return Object.assign([], arr, (_Object$assign = {}, _Object$assign[index] = value, _Object$assign)); }, map: function map(key, value, _map4) { return _map4.set(key, value); }, object: function object(key, value, obj) { var _Object$assign2; return Object.assign({}, obj, (_Object$assign2 = {}, _Object$assign2[key] = value, _Object$assign2)); }, _: function _(key, value, x) { return x[key] = value; } }, 3); function getPath(path, value) { if (path === void 0) { path = []; } return path.reduce(function (parent, key) { return get(key, parent); }, value); } var keys = polymorph({ array: function array(arr) { return arr.map(function (v, i) { return i; }); }, map: function map(_map5) { return Array.from(_map5.keys()); }, object: function object(obj) { return Object.keys(obj); }, _: function _() { return []; } }); function mergePaths() { var path = [].slice.call(arguments).reduce(function (acc, path) { if (path === undefined) return acc; if (isArr(path)) return [].concat(acc, path); return [].concat(acc, [path]); }, []).filter(function (key) { return key !== undefined; }); return path.length > 0 ? path : undefined; } function asKey(key) { if (!isColl(key)) return key; return JSON.stringify(key); } var OPTIONAL = Symbol("OPTIONAL"); var PRED = Symbol("PRED"); var RESULT = Symbol("RESULT"); var 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]]].concat(specEntries(x)).filter(function (_ref) { var y = _ref[1]; return y !== undefined; }).map(function (_ref2) { var key = _ref2[0], y = _ref2[1]; return [key, y]; })); } return x; } var fromSpec = polymorph({ array: function array(spec) { var indices = Array.from(spec.keys()).filter(isNum); var maxIndex = Math.max.apply(Math, indices); var array = Array.from({ length: maxIndex + 1 }, function (_, i) { return spec.get(i); }); array["..."] = spec.get("..."); array[PRED] = spec.get(undefined); return array; }, "function": function _function(spec) { return spec.get(undefined); }, map: function map(spec) { var map = new Map(Array.from(spec.entries()).filter(function (_ref3) { var key = _ref3[0]; return key !== undefined; })); map[PRED] = spec.get(undefined); return map; }, object: function object(spec) { var obj = Object.fromEntries(Array.from(spec.entries()).filter(function (_ref4) { var key = _ref4[0]; return key !== undefined; })); obj[PRED] = spec.get(undefined); return obj; } }, 2); var specEntries = polymorph({ array: function array(arr) { return [].concat(arr.map(function (v, i) { return [i, v]; }), [["...", arr["..."]]]); }, map: function map(_map) { return Array.from(_map.entries()); }, object: function object(obj) { return Object.entries(obj); } }); function opt(selection) { if (selection === void 0) { 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(function (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(function () { return VALID; })["catch"](function (result) { return 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() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } var defaultMessages = { isInvalid: "is invalid", isRequired: "is required" }; function getMessage(key, messages) { if (messages === void 0) { messages = defaultMessages; } return messages[key] || defaultMessages[key]; } function setMessages(messages) { if (messages === void 0) { messages = {}; } Object.assign(defaultMessages, messages); } /* Return a result object of shape * { * valid: true|false|null * promise, * reason, * value, * failedValue, * failedPath, * } */ function _validate(specable, value, _temp) { var _ref = _temp === void 0 ? {} : _temp, argGetFrom = _ref.getFrom, key = _ref.key, path = _ref.path, rootValue = _ref.rootValue; var enhanceArgs = { path: path, value: value }; /* Always valid if not a usable spec */ if (!isSpec(specable)) return enhanceResult(VALID, enhanceArgs); /* Immediately validate collection type */ if (isColl(specable)) { var collType = typeOf(specable); if (typeOf(value) !== collType) return enhanceResult({ valid: false, reason: "must be of type " + collType }, enhanceArgs); } /* Transform into a Map spec */ var spec = toSpec(specable); /* Create value navigation function to act as variable context */ var getFrom = argGetFrom || function (relPath) { return getFromValue(relPath, path, rootValue); }; /* Check own predicate spec */ var pred = spec.get(undefined); var predResult = validatePred(pred, value, getFrom, { key: key }); if (!isColl(specable) || predResult.valid === false) return enhanceResult(predResult, enhanceArgs); /* If spec is a collection one, validate entries, combined with own predicate result */ var results = entries(value).reduce(function (acc, _ref2) { var subKey = _ref2[0], subVal = _ref2[1]; var 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; var result = _validate(subSpec, subVal, { key: subKey, path: mergePaths(path, subKey), rootValue: rootValue || value }); return [].concat(acc, [result]); }, [predResult]); return enhanceResult(interpretCollValidation(results), enhanceArgs); } function validatePred(pred, value, getFrom, options) { var 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) { var defaultReason = pred.name ? "failed '" + pred.name + "'" : getMessage("isInvalid"); try { return pred.apply(void 0, [].slice.call(arguments, 1)) || 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, _ref3) { var path = _ref3.path, value = _ref3.value; var enhanced = res.valid === false ? _extends({}, res, { failedPath: res.failedPath || path, failedValue: res.failedValue || value, value: value }) : res; var promise = res.promise ? res.promise.then(function (promised) { return enhanceResult(promised, { path: path, value: value }); }) : Promise.resolve(enhanced); return _extends({}, enhanced, { promise: 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(function (promisedAns) { return interpretPredAnswer(promisedAns); }) }; return { valid: false, reason: ans || getMessage("isInvalid") }; } function interpretCollValidation(results) { if (results === void 0) { results = []; } /* Any is invalid */ var firstInvalid = results.find(function (_ref4) { var valid = _ref4.valid; return ![null, true].includes(valid); }); if (firstInvalid) return firstInvalid; /* All valid synchronously */ if (results.every(function (_ref5) { var valid = _ref5.valid; return valid === true; })) return VALID; /* Some promises */ var unresolvedResults = results.filter(function (_ref6) { var valid = _ref6.valid; return valid === null; }); return { valid: null, promise: resultsRace(unresolvedResults).then(function (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) { if (currPath === void 0) { currPath = []; } var newPath = relPath.split("/").reduce(function (acc, move) { if ([null, undefined, "", "."].includes(move)) return acc; if (move.startsWith("..")) return acc.slice(0, -1); var index = parseInt(move, 10); return [].concat(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() { var realPreds = [].slice.call(arguments).map(function (pred) { return isFunc(pred) ? pred : function () { return true; }; }); if (realPreds.length <= 1) return realPreds[0]; function combinedPred() { var _arguments = arguments; var results = realPreds.map(function (pred) { return validatePred.apply(void 0, [pred].concat([].slice.call(_arguments))); }); /* Any is invalid */ var firstInvalid = results.find(function (_ref) { var valid = _ref.valid; return ![null, true].includes(valid); }); if (firstInvalid) { if (isResult(firstInvalid)) return firstInvalid; return firstInvalid.reason; } /* All valid synchronously */ if (results.every(function (_ref2) { var valid = _ref2.valid; return valid === true; })) return true; /* Some promises */ var unresolvedResults = results.filter(function (_ref3) { var valid = _ref3.valid; return valid === null; }); return resultsRace(unresolvedResults).then(function (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. */ var maxLength = realPreds.reduce(function (acc, pred) { return Math.max(acc, pred.length); }, 0); Object.defineProperty(combinedPred, "length", { value: maxLength }); return combinedPred; } function and() { var specables = [].slice.call(arguments); var firstColl = specables.find(isColl); if (!firstColl) return combinePreds.apply(void 0, specables); var spec = fromSpec(mergeSpecs.apply(void 0, specables), firstColl); return specables.every(isOpt) ? opt(spec) : spec; } function mergeSpecs() { var specs = [].slice.call(arguments).map(toSpec); var allKeys = new Set(specs.flatMap(function (spec) { return Array.from(spec.keys()); })); return new Map(Array.from(allKeys.values()).map(function (key) { var keySubSpecs = specs.filter(function (spec) { return spec.has(key); }).map(function (spec) { return spec.get(key); }); return [key, and.apply(void 0, keySubSpecs)]; })); } function spread(spec, coll) { if (coll === void 0) { 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) { var reqEntries = entries(selection).filter(function (_ref) { var k = _ref[0], v = _ref[1]; return k !== "..." && !!v; }); /* Append all collection keys as requirement entries if a spread is defined. */ var spreadSelection = getSpread(selection); if (spreadSelection && !(isOpt(spreadSelection) && coll === undefined)) { reqEntries = [].concat(reqEntries, keys(coll).map(function (k) { return [k, spreadSelection]; })); } /* Get the top level required keys */ var reqKeys = reqEntries.reduce(function (acc, _ref2) { var k = _ref2[0], v = _ref2[1]; if (isColl(v) && isOpt(v)) return acc; return [].concat(acc, [k]); }, []); var missingKey = reqKeys.find(function (k) { return isNil(get(k, coll)); }); if (missingKey !== undefined) return mergePaths(asKey(currKey), missingKey); /* Drill down recursively into sub paths */ var missingSubKey = reqEntries.reduce(function (acc, _ref3) { var k = _ref3[0], subReq = _ref3[1]; if (acc !== undefined) return acc; if (!isColl(subReq)) return undefined; var optional = isOpt(subReq); var 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; var explicitSelectionMap = new Map(entries(selection).filter(function (_ref4) { var k = _ref4[0]; return k !== "..."; })); var spreadSelection = getSpread(selection); if (!spreadSelection && explicitSelectionMap.size <= 0) return value; return fromMap(new Map(Array.from(entries(value)).filter(function (_ref5) { var key = _ref5[0]; return explicitSelectionMap.has(key) ? explicitSelectionMap.get(key) : !!spreadSelection; }).map(function (_ref6) { var key = _ref6[0], val = _ref6[1]; return [key, select(explicitSelectionMap.get(key) || spreadSelection, val)]; }).filter(function (_ref7) { var val = _ref7[1]; return val !== undefined; })), value); } function createSelection(_ref8) { var selection = _ref8.selection, spec = _ref8.spec, required = _ref8.required; if (!selection) return undefined; if (isColl(selection)) return selection; return mergeColls(spec, required); } function mergeColls() { var colls = [].slice.call(arguments).filter(isColl); if (colls.length < 2) return colls[0]; var merged = colls.reduce(function (a, b) { return merge__default["default"](a, b, { arrayMerge: combineArrays }); }); return colls.every(isOpt) ? opt(merged) : merged; } function combineArrays(target, source, options) { var destination = target.slice(); source.forEach(function (item, index) { if (destination[index] === undefined) { destination[index] = options.cloneUnlessOtherwiseSpecified(item, options); } else if (options.isMergeableObject(item)) { destination[index] = merge__default["default"](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, _temp, cb) { var _ref = _temp === void 0 ? {} : _temp, _ref$messages = _ref.messages, messages = _ref$messages === void 0 ? defaultMessages : _ref$messages, required = _ref.required, _ref$selection = _ref.selection, sel = _ref$selection === void 0 ? false : _ref$selection; if (cb === void 0) { cb = function cb() {}; } var selection = createSelection({ selection: sel, spec: specable, required: required }); var prunedValue = selection ? select(selection, value) : value; var enhancedSpecable = specable; if (isColl(specable) && !!required) { enhancedSpecable = and(function requireSpec(value) { var missingPath = findMissingPath(required, value); if (missingPath === undefined) return true; var res = { valid: false, reason: getMessage("isRequired", messages), failedPath: missingPath }; return tagAsResult(res); }, specable); } var result = _validate(enhancedSpecable, prunedValue, { rootValue: prunedValue }); var enhanced = _extends({}, result, { value: prunedValue }); cb(enhanced); if (enhanced.valid === null) enhanced.promise.then(cb); return enhanced; } function validateAsync() { return validate.apply(void 0, [].slice.call(arguments)).promise; } /* Return `true` if valid, error reason if invalid * or a promise that will return those. */ var checkAsync = function checkAsync() { try { var _arguments2 = arguments; return Promise.resolve(check.apply(void 0, [].slice.call(_arguments2))); } catch (e) { return Promise.reject(e); } }; function check(spec, value, options, cb) { if (cb === void 0) { cb = function cb() {}; } return interpretCheck(validate(spec, value, options, function (result) { return cb(interpretCheck(result)); })); } function interpretCheck(result) { if (result.valid === null) return result.promise.then(interpretCheck); if (result.valid === true) return true; return enhanceReason(result); } function enhanceReason(_ref) { var reason = _ref.reason, _ref$failedPath = _ref.failedPath, failedPath = _ref$failedPath === void 0 ? [] : _ref$failedPath; var key = failedPath.join("."); var stringified = typeof reason === "object" ? JSON.stringify(reason) : reason.toString(); var 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) { if (cb === void 0) { cb = function cb() {}; } return interpretConform(validate(spec, value, options, function (response) { return cb(interpretConform(response)); })); } function conformAsync() { return Promise.resolve(conform.apply(void 0, [].slice.call(arguments))); } function interpretConform(result) { if (result.valid === true) return result.value; if (result.valid === null) return result.promise.then(function (promised) { return interpretConform(promised); }); var 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) { if (cb === void 0) { cb = function cb() {}; } return interpretIsValid(check(spec, value, options, function (response) { return cb(interpretIsValid(response)); })); } function isValidAsync() { return Promise.resolve(isValid.apply(void 0, [].slice.call(arguments))); } 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 function (value, getFrom, options) { if (options === void 0) { options = {}; } var result = validatePred(keySpec, options.key, getFrom, options); if (result.valid === false) result.failedValue = options.key; return tagAsResult(_extends({}, result, { keyValidation: true })); }; } function or() { var specs = [].slice.call(arguments).filter(isSpec); return function _or(value, getFrom) { var results = specs.map(function (spec) { return _validate(spec, value, { getFrom: getFrom }); }); return interpretResults(results); }; } function interpretResults(results) { /* If any response is already valid, `or` is valid. */ if (results.some(function (_ref) { var valid = _ref.valid; return valid === true; })) return true; var pendingResults = results.filter(function (_ref2) { var valid = _ref2.valid; return 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(function (_ref3) { var valid = _ref3.valid; return valid === true; })) return true; /* Otherwise, return the first failed result */ var lastInvalid = results.find(function (_ref4) { var valid = _ref4.valid; return 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(function (promisedRes) { if (promisedRes.valid !== true) return promisedRes; throw promisedRes; }); } function raceValidResult(results) { return Promise.all(results.map(failIfValidAsync)).then(interpretResults)["catch"](function () { return true; }); } var util = { entries: entries, fromMap: fromMap, get: get, getPath: getPath, keys: keys, mergePaths: mergePaths, set: set, typeOf: typeOf }; exports.and = and; exports.check = check; exports.checkAsync = checkAsync; exports.conform = conform; exports.conformAsync = conformAsync; exports.getMessage = getMessage; exports.getPred = getPred; exports.getSpread = getSpread; exports.isOpt = isOpt; exports.isValid = isValid; exports.isValidAsync = isValidAsync; exports.key = key; exports.opt = opt; exports.or = or; exports.select = select; exports.setMessages = setMessages; exports.spread = spread; exports.util = util; exports.validate = validate; exports.validateAsync = validateAsync; exports.validatePred = validatePred; }));