@dreesq/serpent
Version:
An express wrapper for developing fast web applications
780 lines (614 loc) • 17.5 kB
JavaScript
const crypto = require('crypto');
const {promisify} = require('util');
const fs = require('fs');
const path = require('path');
const {MODULE_PATH} = require('./constants');
const serpent = require('./');
const mongoose = require('mongoose');
/**
* Promisify functions
*/
const stat = promisify(fs.stat);
const readdir = promisify(fs.readdir);
/**
* Helper for getting object value by path
* @param obj
* @param path
* @param defaultValue
*/
exports.get = get = (obj, path, defaultValue = false) => {
let value = path.split('.').reduce((current, key) => (current && current.hasOwnProperty(key) ? current[key] : undefined), obj);
return (typeof value !== 'undefined' ? value : defaultValue);
};
/**
* Given an object, return selected fields
* @param obj
* @param fields
*/
exports.select = select = (obj = {}, fields) => {
if (!Array.isArray(fields)) {
return obj;
}
let actions = {};
for (let key of fields) {
let remove = key[0] === '-';
if (remove) {
key = key.substr(1, key.length);
actions[key] = -1;
}
if (!remove) {
actions[key] = 1;
}
}
for (let key in obj) {
if (actions[key] === 1) {
continue;
}
delete obj[key];
}
return obj;
};
/**
* Remove keys utility
* @param obj
* @param args
* @returns object
*/
exports.removeKeys = (obj, args = []) => {
let copied = {...obj};
for (let arg of args) {
delete copied[arg];
}
return copied;
};
/**
* Helper for creating tokens
* @param length
* @returns {Promise<*>}
*/
exports.makeToken = async (length = 64) => {
const crypto = serpent.plugin('crypto');
return await crypto.random(length);
};
/**
* Error helper
* @param data
* @returns {{errors: {message: string[]}}}
*/
exports.error = error = (data = '') => {
let res = {
errors: {
message: [data]
}
};
if (typeof data === 'object') {
if (data.message) {
res.errors.message[0] = data.message;
} else {
res.errors = data;
}
if (data.debug) {
res.debug = data.debug;
}
}
return res;
};
/**
* Success helper
* @param data
*/
exports.success = success = (data = '') => {
if (!data) {
return {
success: true
};
}
return Array.isArray(data) ? {data} : data;
};
/**
* Helper function for loading folder
* @param appPath
* @param type
* @param callback
* @param requireFile
* @returns {Promise<void>}
*/
exports.load = async (appPath = MODULE_PATH, type = 'actions', callback = false, requireFile = true) => {
let entities = [];
let logger = serpent.plugin('logger');
d(`load (${type}) <- (${appPath})`);
try {
entities = await readdir(`${appPath}/${type}`);
} catch(e) {
logger.error(`Failed to read path (${appPath}/${type}), error:`, e);
}
for (const entity of entities) {
let entityPath = path.join(appPath, type, entity);
let itemStat = await stat(entityPath);
if (!itemStat.isFile()) {
continue;
}
let required;
if (requireFile) {
required = require(entityPath);
} else {
required = entityPath;
}
if (callback) {
callback(entity, required);
}
}
};
/**
* Debug helper
* @param args
*/
exports.d = d = (...args) => {
const config = serpent.plugin('config', false);
const logger = serpent.plugin('logger', console);
if (!config || !config.get || !config.get('debug', false)) {
return;
}
args.unshift('(s)');
return logger.debug(args.join(' '));
};
/**
* Auto generate crud methods
* @param model
* @param options
*/
exports.autoCrud = async (model, options = {}) => {
const methods = [
'create',
'remove',
'update',
'get',
'find'
];
const defaults = {
methods,
name: model,
select: null,
middleware: [],
schema: {},
type: 'actions',
allowNull: false,
restrictToUser: false,
after(ctx, method, data) {
return data;
},
before(ctx, method, filters) {
return filters;
}
};
options = {
...defaults,
...options
};
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);
/**
* Name the method
* @param method
* @returns {*}
*/
const nameMethod = method => {
const {name, type = 'action', path = '', visible = true} = options;
if (type === 'rest') {
const methods = {
create: 'post',
update: 'put',
remove: 'delete',
find: 'get',
get: 'get'
};
const toRoutePath = (method) => {
return {
'create': `${path}`,
'update': `${path}/:id`,
'remove': `${path}/:id`,
'find': `${path}`,
'get': `${path}/:id`
}[method];
};
return {
route: [methods[method], toRoutePath(method)]
}
}
return {
name: `auto${capitalize(method)}${name}`,
visible
};
};
/**
* Define the method
* @param model
* @param method
* @returns {Function}
*/
const defineMethod = (model, method) => {
return async ctx => {
const {db, input} = ctx;
const collection = db[model];
const {before, after} = options;
if (!collection) {
return error(`Could not find the given collection.`);
}
let data = false;
let filters = {};
let fields = Array.isArray(options.fields) ? options.fields.join(' ') : null;
if (['update', 'remove', 'get'].includes(method)) {
let id = input.id;
if (id) {
filters._id = mongoose.Types.ObjectId(id);
}
if (options.restrictToUser) {
filters.userId = ctx.user._id;
}
if (!id && !options.allowNull) {
return error(`Method does not support null filtering.`);
}
}
let autoFilterBefore;
if (typeof before === 'function') {
autoFilterBefore = await before(ctx, method, filters);
}
if (method === 'get') {
data = await collection.findOne(filters, fields);
data = data || {};
}
if (method === 'find') {
const opts = {
pagination: true,
fields,
restrictToUser: options.restrictToUser
};
if (typeof autoFilterBefore === 'function') {
opts.before = autoFilterBefore;
}
data = await autoFilter(model, opts)(ctx);
}
if (method === 'create') {
delete input._id;
data = await collection.create(input);
data = select(data._doc, options.fields);
}
if (method === 'remove') {
await collection.deleteMany(filters);
data = {
success: true
};
}
if (method === 'update') {
delete input._id;
await collection.update(filters, {
$set: input
});
const updated = await collection.find(filters, fields);
data = updated.length === 1 ? updated[0] : updated;
}
if (typeof after === 'function') {
return await after(ctx, method, data);
}
return data;
};
};
for (const method of options.methods) {
if (!methods.includes(method)) {
throw new Error(`Invalid method ${method}.`);
}
const actionOptions = {
...nameMethod(method),
middleware: options.middleware,
};
let input = {};
if (['create', 'update'].includes(method)) {
if (typeof options.schema === 'function') {
input = await options.schema(method);
}
if (typeof options.schema === 'object') {
input = {...options.schema};
}
}
if (method === 'update') {
input.id = 'required|string|min:24'
}
actionOptions.input = input;
config(actionOptions)(defineMethod(model, method));
}
};
/**
* Auto filter model helper
* @param model
* @param options
* @returns {Function}
*/
exports.autoFilter = autoFilter = (model, options = {}) => {
return async ctx => {
const {input, db, user} = ctx;
let collection = db[model];
if (!collection) {
return error(`Could not find the given collection.`);
}
const {filters = {}, sorts = {}, page = 1} = input;
const extra = [];
const queryProxy = new Proxy({}, {
get(target, name,) {
return (...args) => {
extra.push([name, args]);
return queryProxy;
}
}
});
if (options.before) {
await options.before(queryProxy, filters, ctx);
}
let makeQuery = () => {
let query = collection.find();
for (let [method, args] of extra) {
query[method](...args);
}
if (options.restrictToUser) {
query.where({userId: user._id});
}
if (options.fields) {
query.select(options.fields);
}
return query;
};
let query = makeQuery();
if (Object.keys(sorts).length) {
query.sort(sorts);
} else {
query.sort({
_id: -1
});
}
const limit = options.limit || 5;
limit !== -1 && query.limit(limit);
let count;
const skip = (page - 1 || 0) * limit;
if (options.pagination) {
limit !== -1 && query.skip(skip);
count = makeQuery().countDocuments();
}
let [data, total] = await Promise.all([query, count]);
if (options.pagination) {
const pages = Math.ceil(total / limit);
data = {
data,
pagination: {
page: limit === -1 ? 1 : page,
pages: limit === -1 ? 1 : pages,
hasMoreItems: limit === -1 ? false : page < pages
}
}
}
if (options.after) {
data = await options.after(data, filters, ctx);
}
return data;
};
};
/**
* Parse template helper
* @param string
* @param data
* @returns {Object|void|*}
*/
exports.parseTemplate = (string, data = {}) => {
return string.replace(/{{\s*([^}]*)\s*}}/g, (match, $1) => {
let key = $1.trim();
/**
* Handle pluralization
*/
if (key.indexOf(':') > -1) {
let [newKey, options] = key.split(':');
let value = +get(data, newKey, `[${newKey}]`);
options = options.split('|');
let index = value === 0 ? 0 : (value === 1 ? 1 : (value > 1 ? 2 : false));
if (!isNaN(index)) {
let result = options[index];
return result.indexOf('_') > -1 ? result.replace(/_/g, value) : result[index];
}
}
return get(data, key, `[${key}]`);
});
};
/**
* Query string helper
* @param obj
* @returns {string}
*/
exports.toQueryString = (obj = {}) => {
let result = Object.keys(obj).map(key => key + '=' + encodeURIComponent(obj[key])).join('&');
return result.length ? `?${result}` : '';
};
/**
* Capitalizes given text
* @param text
* @returns {string}
*/
exports.capitalize = capitalize = (text = '') => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
/**
* Dotted to capitalized name
* @param model
* @returns {string}
*/
exports.toModelName = (model = '') => {
return model.split('.').map(part => capitalize(part)).join('')
};
/**
* Stripe hook utility
* @param options
* @returns {Promise<void>}
*/
exports.stripeHook = ({onSubscribe, onUnsubscribe, onEvent, onRefund}) => {
const {stripe, config} = serpent.getPlugins();
const whKey = config.get('plugins.stripe.whKey');
return async ctx => {
const {req, res} = ctx;
const {client} = stripe.build(req);
const sig = req.headers['stripe-signature'];
let event;
try {
event = client.webhooks.constructEvent(req.rawBody, sig, whKey);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
const type = event.type;
const data = event.data.object;
onEvent && await onEvent(type, data, ctx);
if (type === 'charge.refunded') {
onRefund && await onRefund(data, ctx);
}
if (type === 'customer.subscription.created') {
onSubscribe && await onSubscribe(data, ctx);
}
if (type === 'customer.subscription.deleted') {
onUnsubscribe && await onUnsubscribe(data, ctx);
}
res.end();
};
};
/**
* Generates a form structure for client side auto generation
* @param field
* @returns {Buffer | Buffer}
*/
exports.encodeField = encodeField = (field = {}) => {
let result = new Buffer(JSON.stringify(field)).toString('base64');
return `form:${result}`;
};
/**
* Generates a hook runner based on given options
* @param options
* @returns {Function}
*/
exports.hookRunner = (options = {}) => {
return (hook, ...args) => {
if (!options.hooks)
return;
if (typeof options.hooks[hook] === 'function')
return options.hooks[hook](...args);
}
};
/**
* Given a string escapes it
* @param text
*/
exports.escape = (text = '') => {
let map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, match => map[match]);
};
/**
* Given an input and a form structure, maps its data to form base64
* @param form
*/
exports.form = (form = {}) => {
let result = {};
for (const key in form) {
const {validation = false, ...others} = form[key];
result[key] = `${encodeField(others)}|${validation}`;
}
return result;
};
/**
* Dotize object helper
* @param obj
* @param prefix
*/
exports.dotize = (obj, prefix) => {
let result = {};
const recurse = (o, p) => {
for (const f in o) {
let pre = (p === undefined ? '' : p + ".");
if (o[f] && typeof o[f] === "object"){
result = recurse(o[f], pre + f);
} else {
result[pre + f] = o[f];
}
}
return result;
};
return recurse(obj, prefix);
};
/**
* Sets a value to the object using dot notation
* @param obj
* @param key
* @param value
*/
exports.set = (obj = {}, key = '', value) => {
const keys = key.split('.');
if (!keys.length) {
return;
}
let current = obj;
let i = 0;
let total = keys.length;
while (keys[i]) {
let isLastIteration = i === total - 1;
if (!current.hasOwnProperty(keys[i]) && !isLastIteration) {
current[keys[i]] = {};
}
if (!isLastIteration) {
current = current[keys[i]];
}
if (isLastIteration) {
current[keys[i]] = value;
}
++i;
}
};
/**
* Removes a key from an object using dot notation
* @param obj
* @param key
*/
exports.unset = (obj = {}, key = '') => {
const keys = key.split('.');
if (!keys.length) {
return;
}
let current = obj;
let i = 0;
let total = keys.length;
while (keys[i]) {
let isLastIteration = i === total - 1;
if (!current.hasOwnProperty(keys[i])) {
break;
}
if (!isLastIteration) {
current = current[keys[i]];
}
if (isLastIteration) {
delete current[keys[i]];
}
++i;
}
};
/**
* Given an options string, attempts to parse it
* @param options
* @returns {{}}
*/
exports.parseOptions = options => {
if (!options || typeof options !== 'string') {
return {};
}
let result = {};
let keys = options.split(options.indexOf('|') > -1 ? '|' : ',');
for (const key of keys) {
let [name, opts] = key.split(':');
opts = opts && opts.length ? opts.replace(/\s+/gi, '').split(',') : [true];
opts = opts.map(opt => isNaN(opt) ? opt : Number(opt));
result[name] = opts.length === 1 ? opts[0] : opts;
}
return result;
};