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