specma
Version:
Simple, reusable and composable validation
963 lines (850 loc) • 28.1 kB
JavaScript
(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;
}));