cartography
Version:
Object to Object mapper
234 lines (196 loc) • 7.48 kB
JavaScript
(function () {
// This can be thrown by a filter to prevent successive filters from being executed
function FilterChainBreak(value) {
this.value = value;
}
// This is thrown by a filter when an input is invalid
// It is thrown and caught in a back recursion to add the path of the invalid input to the error message string
function CartographyError(message) {
Error.captureStackTrace(this);
this.message = ': ' + message;
}
CartographyError.prototype = Object.create(Error.prototype);
CartographyError.prototype.name = 'CartographyError';
function isCartographyError(err) {
return err instanceof CartographyError;
}
function runFiltersOn(value, filters, decorateMessage) {
try {
return filters.reduce(function (v, filter) {
return filter(v);
}, value);
} catch (exception) {
if (exception instanceof FilterChainBreak) return exception.value;
if (isCartographyError(exception)) exception.message = decorateMessage(exception.message);
throw exception;
}
}
function addSeparator(sourceAttributePath, message) {
// check that message is like "foo: bar" and neither ": bar" nor "[0]: bar"
const sep = /^[^[:].*:/.test(message) ? '.' : '';
return sourceAttributePath + sep + message;
}
// Main mapping function
function map(source, schema) {
// Build destination attribute via a filters chain
function runFilters(args, destinationAttribute) {
let sourceAttributePath, filters, value;
// First argument is the source attribute path, all others are filter functions
if (typeof args[0] === 'string') {
sourceAttributePath = args[0];
filters = args.slice(1);
value = sourceAttributePath.split('.').reduce(function (o, step) {
return (o || {})[step];
}, source);
}
// All arguments are filter functions
else {
sourceAttributePath = destinationAttribute;
filters = args;
value = (source || {})[sourceAttributePath];
}
function formatError(message) {
return addSeparator(sourceAttributePath, message);
}
return runFiltersOn(value, filters, formatError);
}
// Build destination attribute via a custom function
function callCustomFunction(fn) {
return fn(source);
}
// Destination attribute is an Object with a nested schema
function goRecursive(nestedSchema) {
return map(source, nestedSchema);
}
// Error: a string was used. Throw an exception
// eslint-disable-next-line no-unused-vars
function rejectString(s, destinationAttribute) {
throw new Error('invalid schema for `' + destinationAttribute + '`');
}
const destination = {};
let length = 0;
for (const destinationAttribute in schema) {
let value = schema[destinationAttribute];
const methodValue = typeof value === 'string' ? rejectString : goRecursive;
const funcValue = typeof value === 'function' ? callCustomFunction : methodValue;
const method = Array.isArray(value) ? runFilters : funcValue;
value = method(value, destinationAttribute);
if (value != null) {
destination[destinationAttribute] = value;
length++;
}
}
if (length) return destination;
return undefined;
}
function flatten(array, flat, start) {
const type = Object.prototype.toString.call(array);
if (type !== '[object Array]' && type !== '[object Arguments]')
throw new Error('filter must be function or Array, but ' + type + ' found');
for (let i = start; i < array.length; i++) {
const e = array[i];
if (typeof e === 'function') flat.push(e);
else flatten(e, flat, 0);
}
return flat;
}
function same() {
return flatten(arguments, [], 0);
}
function from() {
const path = arguments[0];
if (typeof path !== 'string') throw new Error('first argument must be a string');
return [path].concat(flatten(arguments, [], 1));
}
function assertFactory(test, message) {
return function (v) {
if (test(v)) return v;
throw new CartographyError(typeof message === 'function' ? message(v) : message);
};
}
const filters = {
array: function () {
const arrayFilters = flatten(arguments, [], 0);
return function (a) {
if (!Array.isArray(a)) throw new CartographyError('must be an Array');
return a.map(function (element, index) {
return runFiltersOn(element, arrayFilters, function (message) {
return addSeparator('[' + index + ']', message);
});
});
};
},
object: function (schema) {
return function (v) {
return map(v, schema);
};
},
optional: function (v) {
if (v != null) return v;
throw new FilterChainBreak();
},
defaults: function (defaultValue) {
return function (v) {
if (v != null) return v;
throw new FilterChainBreak(defaultValue);
};
},
required: assertFactory(function (v) {
return v != null;
}, 'is required'),
assert: assertFactory,
parseJSON: function (s) {
try {
return JSON.parse(s);
} catch (e) {
throw new CartographyError('invalid JSON: ' + e.message);
}
},
isOneOf: function (valids) {
return assertFactory(function (v) {
return ~valids.indexOf(v);
}, 'unrecognized value');
},
isString: assertFactory(function (v) {
//return Object.prototype.toString.call(v) === '[object String]'
return typeof v === 'string';
}, 'must be a string'),
isNumber: assertFactory(function (v) {
//return Object.prototype.toString.call(v) === '[object Number]' && !isNaN(v)
return typeof v === 'number' && !isNaN(v);
}, 'must be a number'),
isInteger: assertFactory(function (v) {
//return v === Math.floor(v) && Object.prototype.toString.call(v) === '[object Number]'
return v === Math.floor(v) && typeof v === 'number';
}, 'must be an integer number'),
isEmail: assertFactory(function (v) {
return v?.match(
// https://emailregex.com/
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}])|(([a-zA-Z\-\d]+\.)+[a-zA-Z]{2,}))$/
);
}, 'must be a valid email address'),
isUrl: assertFactory(function (v) {
return v?.match(
// https://gist.github.com/dperini/729294
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\d\u00a1-\uffff][a-z\d\u00a1-\uffff_-]{0,62})?[a-z\d\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/
);
}, 'must be a valid URL'),
};
function mapArray(source, schema, arrayFilters = []) {
const arraySchema = Array.isArray(schema) ? schema : filters.object(schema);
const { array } = map({ array: source }, { array: same(arrayFilters, filters.array(arraySchema)) }) || {};
return array;
}
const exports = {
CartographyError,
FilterChainBreak,
isCartographyError,
map,
mapArray,
same,
from,
filters,
};
if (typeof module !== 'undefined') module.exports = exports;
return exports;
})();