bedrock
Version:
A core foundation for rich Web applications.
500 lines (469 loc) • 13.8 kB
JavaScript
/*!
* Copyright (c) 2012-2019 Digital Bazaar, Inc. All rights reserved.
*/
'use strict';
const _ = require('lodash');
const config = require('./config');
const delay = require('delay');
const util = require('util');
const uuid = require('uuid-random');
const api = {};
module.exports = api;
/**
* Create a promise which resolves after the specified milliseconds.
*
* @see {@link https://github.com/sindresorhus/delay}
*/
api.delay = delay;
// BedrockError class
api.BedrockError = function(message, type, details, cause) {
Error.call(this, message);
Error.captureStackTrace(this, this.constructor);
this.name = type;
this.message = message;
this.details = details || null;
this.cause = cause || null;
};
util.inherits(api.BedrockError, Error);
api.BedrockError.prototype.name = 'BedrockError';
api.BedrockError.prototype.toObject = function(options) {
options = options || {};
options.public = options.public || false;
// convert error to object
const rval = _toObject(this, options);
// add stack trace only for non-public development conversion
if(!options.public && config.core.errors.showStack) {
// try a basic parse
rval.stack = _parseStack(this.stack);
}
return rval;
};
// check type of this error
api.BedrockError.prototype.isType = function(type) {
return api.hasValue(this, 'name', type);
};
// check type of this error or one of it's causes
api.BedrockError.prototype.hasType = function(type) {
return this.isType(type) || this.hasCauseOfType(type);
};
// check type of error cause or one of it's causes
api.BedrockError.prototype.hasCauseOfType = function(type) {
if(this.cause && this.cause instanceof api.BedrockError) {
return this.cause.hasType(type);
}
return false;
};
const _genericErrorJSON = {
message: 'An internal server error occurred.',
type: 'bedrock.InternalServerError'
};
const _errorMessageRegex = /^Error:\s*/;
const _errorAtRegex = /^\s+at\s*/;
/**
* Parse an Error stack property into a JSON structure.
*
* NOTE: Uses some format heuristics and may be fooled by tricky errors.
*
* TODO: look into better stack parsing libraries.
*/
function _parseStack(stack) {
try {
const lines = stack.split('\n');
const messageLines = [];
const atLines = [];
for(let i = 0; i < lines.length; ++i) {
const line = lines[i];
// push location-like lines to a stack array
if(line.match(_errorAtRegex)) {
atLines.push(line.replace(_errorAtRegex, ''));
} else {
// push everything else to a message array
messageLines.push(line.replace(_errorMessageRegex, ''));
}
}
return {
message: messageLines.join('\n'),
at: atLines
};
} catch(e) {
// FIXME: add parse error handling
return stack;
}
}
function _toObject(err, options) {
if(!err) {
return null;
}
if(options.public) {
// public conversion
// FIXME also check if a validation type?
if(err instanceof api.BedrockError &&
err.details && err.details.public) {
const details = api.clone(err.details);
delete details.public;
// mask cause if it is not a public bedrock error
let {cause} = err;
if(!(cause && cause instanceof api.BedrockError &&
cause.details && cause.details.public)) {
cause = null;
}
return {
message: err.message,
type: err.name,
details,
cause: _toObject(cause, options)
};
} else {
// non-bedrock error or not public, return generic error
return _genericErrorJSON;
}
} else {
// full private conversion
if(err instanceof api.BedrockError) {
return {
message: err.message,
type: err.name,
details: err.details,
cause: _toObject(err.cause, options)
};
} else {
return {
message: err.message,
type: err.name,
details: {
inspect: util.inspect(err, false, 10),
stack: _parseStack(err.stack)
},
cause: null
};
}
}
}
/**
* Gets the passed date in W3C format (eg: 2011-03-09T21:55:41Z).
*
* @param date the date.
*
* @return the date in W3C format.
*/
api.w3cDate = function(date) {
if(date === undefined || date === null) {
date = new Date();
} else if(typeof date === 'number' || typeof date === 'string') {
date = new Date(date);
}
return util.format('%d-%s-%sT%s:%s:%sZ',
date.getUTCFullYear(),
_zeroFill(date.getUTCMonth() + 1),
_zeroFill(date.getUTCDate()),
_zeroFill(date.getUTCHours()),
_zeroFill(date.getUTCMinutes()),
_zeroFill(date.getUTCSeconds()));
};
function _zeroFill(num) {
return (num < 10) ? '0' + num : '' + num;
}
/**
* Merges the contents of one or more objects into the first object.
*
* @param deep (optional), true to do a deep-merge.
* @param target the target object to merge properties into.
* @param objects N objects to merge into the target.
*
* @return the default Bedrock JSON-LD context.
*/
api.extend = function() {
let deep = false;
let i = 0;
if(arguments.length > 0 && typeof arguments[0] === 'boolean') {
deep = arguments[0];
++i;
}
const target = arguments[i] || {};
i++;
for(; i < arguments.length; ++i) {
const obj = arguments[i] || {};
Object.keys(obj).forEach(function(name) {
const value = obj[name];
if(deep && api.isObject(value) && !Array.isArray(value)) {
target[name] = api.extend(true, target[name], value);
} else {
target[name] = value;
}
});
}
return target;
};
/**
* Returns true if the given value is an Object.
*
* @param value the value to check.
*
* @return true if it is an Object, false if not.
*/
api.isObject = function(value) {
return (Object.prototype.toString.call(value) === '[object Object]');
};
/**
* Clones a value. If the value is an array or an object it will be deep cloned.
*
* @param value the value to clone.
*
* @return the clone.
*/
api.clone = function(value) {
if(value && typeof value === 'object') {
let rval;
if(Array.isArray(value)) {
rval = new Array(value.length);
for(let i = 0; i < rval.length; i++) {
rval[i] = api.clone(value[i]);
}
} else {
rval = {};
for(const j in value) {
rval[j] = api.clone(value[j]);
}
}
return rval;
}
return value;
};
// config utilities
// config namespace
api.config = {};
// check if argument looks like a string or array path
function _isPath(maybePath) {
return typeof maybePath === 'string' || Array.isArray(maybePath);
}
// set default for path if it does not exist
function _setDefault(object, path, value) {
// ensure path is array
if(typeof path === 'string') {
path = _.toPath(path);
}
if(path.length) {
let target = _.get(object, path);
if(!target) {
target = value;
_.set(object, path, target);
}
return target;
} else {
return object;
}
}
/**
* Wrapper with helpers for config objects.
*
* @param object the config object.
* @param [options] options to use:
* config: parent config object
* locals: object containing variables used for string templates.
* Defaults to main config object.
*/
api.config.Config = function(object, options) {
this.object = object;
this.options = options || {};
};
/**
* Set a path to a value.
*
* Multiple paths can be set at once with an object with many string path keys
* and associated values.
*
* @param path lodash-style string or array path, or an object with many path
* key and value pairs.
* @param value value to set at the path when using single path.
*/
api.config.Config.prototype.set = function(path, value) {
if(!_isPath(path)) {
Object.keys(path).forEach(key => _.set(this.object, key, path[key]));
return;
}
_.set(this.object, path, value);
};
/**
* Set a path to a default value if it does not exist. All elements of the path
* will be initialized as an empty object if they do not exist.
*
* Multiple paths can be set at once with an object with many string path keys
* and associated default values;
*
* Note: To initialize the final element of a path to the empty object even if
* it already exists, use c.set(path, {});
*
* @param path lodash-style string or array path, or an object with many path
* key and default value pairs.
* @param value default value to set at the path when using a single path.
* @return the last element of the path or a path indexed object with element
* values.
*/
api.config.Config.prototype.setDefault = function(path, value) {
if(!_isPath(path)) {
const paths = {};
Object.keys(path).forEach(key => {
paths[key] = _setDefault(this.object, key, path[key]);
});
return paths;
}
return _setDefault(this.object, path, value);
};
/**
* Assigns a getter to a config path. When the config path is read, the getter
* will execute and compute the configured value. This is particularly useful
* for config values that depend on other config values; it removes the need
* to update such a value when its dependencies change.
*
* The value can be computed from a function or from a lodash template that
* will be evaluated using `bedrock.config` for its local variables.
*
* @param path lodash-style string or array path, or an object with many path
* key and value pairs.
* @param fnOrExpression a lodash template or a function used to compute the
* path value.
* @param [options] options to use:
* locals: object containing variables used for string templates.
* parentDefault: value for parent if it does not exist.
*/
api.config.Config.prototype.setComputed =
function(path, fnOrExpression, options) {
if(!_isPath(path)) {
options = fnOrExpression;
Object.keys(path).forEach(key => this.setComputed(
key, path[key], options));
return;
}
if(typeof fnOrExpression === 'string') {
// handle strings as templates
fnOrExpression = _.template(fnOrExpression);
} else if(typeof fnOrExpression !== 'function') {
// handle non-string non-functions as simple values
return this.set(path, fnOrExpression);
}
// ensure path is array
if(typeof path === 'string') {
path = _.toPath(path);
}
// locals
options = options || {};
const locals = options.locals || this.options.locals || config;
// get target object path
const targetPath = path.slice(0, -1);
// get key
const targetKey = path.slice(-1);
// get or create target parent object
const parentDefault = options.parentDefault || {};
const target = _setDefault(this.object, targetPath, parentDefault);
// setup property
let _isSet = false;
let _value;
Object.defineProperty(target, targetKey, {
configurable: true,
enumerable: true,
get: () => {
if(_isSet) {
return _value;
}
return fnOrExpression(locals);
},
set: value => {
_isSet = true;
_value = value;
}
});
};
/**
* Create a bound setComputed function for this Config instance. Used to
* simplify code.
*
* let cc = bedrock.util.config.main.computer();
* cc('...', ...);
*
* @return bound setComputed function.
*/
api.config.Config.prototype.computer = function() {
return this.setComputed.bind(this);
};
/**
* Push a getter to an array specified by a config path. See setComputed for an
* explaination of how getters work.
*
* @param path lodash-style string or array path.
* @param fnOrExpression a lodash template or a function used to compute the
* path value.
* @param [options] options to use:
* locals: object containing variables used for string templates.
*/
api.config.Config.prototype.pushComputed =
function(path, fnOrExpression, options) {
// get target or default array
const target = _.get(this.object, path, []);
// add next index
const pushPath = _.toPath(path);
pushPath.push(target.length);
// use default parent array
const pushOptions = Object.assign({}, options, {parentDefault: []});
// set computed array element
this.setComputed(pushPath, fnOrExpression, pushOptions);
};
/**
* Shared wrapper for the standard bedrock config.
*/
api.config.main = new api.config.Config(config);
/**
* Generates a new v4 UUID.
*
* @return the new v4 UUID.
*/
api.uuid = uuid;
/**
* Parse the string or value and return a boolean value or raise an exception.
* Handles true and false booleans and case-insensitive 'yes', 'no', 'true',
* 'false', 't', 'f', '0', '1' strings.
*
* @param value a string of value.
*
* @return the boolean conversion of the value.
*/
api.boolify = function(value) {
if(typeof value === 'boolean') {
return value;
}
if(typeof value === 'string' && value) {
switch(value.toLowerCase()) {
case 'true':
case 't':
case '1':
case 'yes':
case 'y':
return true;
case 'false':
case 'f':
case '0':
case 'no':
case 'n':
return false;
}
}
// if here we couldn't parse it
throw new Error('Invalid boolean:' + value);
};
api.callbackify = fn => {
const callbackVersion = util.callbackify(fn);
return function(...args) {
const callback = args[args.length - 1];
if(typeof callback === 'function') {
return callbackVersion.apply(null, args);
}
return fn.apply(null, args);
};
};
// a replacement for jsonld.hasValue
api.hasValue = (obj, key, value) => {
const t = obj[key];
if(Array.isArray(t)) {
return t.includes(value);
}
return t === value;
};