'use strict';
// Internal dependencies
const render = require('../helpers/render');
const forms = require('../helpers/forms');
const router = require('express').Router();
const getResourceErrorHandler = require('./resource-error-handler');
/**
* This is a generic class to provide middleware for Browse/Read/Edit/Add/Delete
* operations and forms. It comes with some baked-in pre-flight checks but needs
* to be extended to do useful work. All default actions except reads require
* being logged in.
*
* Use the bakery method to create standard BREAD routes. :)
*/
class AbstractBREADProvider {
/**
* @param {IncomingMessage} req
* Express request
* @param {ServerResponse} res
* Express response
* @param {Function} next
* Express callback to move on to next middleware
* @param {Object} [options]
* What kind of route to create
* @param {String} options.action='add'
* one of 'browse', 'read' (view), 'add' (create), 'edit', 'delete'
* @param {String} options.method='GET'
* what HTTP method this route responds to
* @param {String} options.id
* if required, what object ID to look up
* @param {String} options.someOtherID
* will also be assigned to `this`
*/
constructor(req, res, next, options) {
if (new.target === AbstractBREADProvider)
throw new TypeError('AbstractBREADProvider is an abstract class, please instantiate a derived class.');
if (!req || !res || !next)
throw new Error('Form needs at least req, res, and next functions from middleware.');
this.actions = {
browse: {
// Function to call for GET requests
GET: this.browse_GET,
// Checks to perform before either of above functions are called.
// If checks fail, they are not called (checks have to handle
// the request).
preFlightChecks: [],
// Title for all "browse" actions
titleKey: undefined
},
read: {
GET: this.read_GET,
preFlightChecks: [],
// Function to call to load data and pass it to GET/POST function.
// This must perform exclusion of deleted or stale revisions.
loadData: this.loadData,
titleKey: undefined
},
add: {
GET: this.add_GET,
// Function to call for POST requests
POST: this.add_POST,
preFlightChecks: [this.userIsSignedIn],
titleKey: undefined
},
edit: {
GET: this.edit_GET,
POST: this.edit_POST,
preFlightChecks: [this.userIsSignedIn],
// Function to call to load data and pass it to GET/POST function
loadData: this.loadData,
// Function to call to validate that user can perform this action,
// once we have a resource to check against.
resourcePermissionCheck: this.userCanEdit,
titleKey: undefined
},
delete: {
GET: this.delete_GET,
POST: this.delete_POST,
preFlightChecks: [this.userIsSignedIn],
loadData: this.loadData,
resourcePermissionCheck: this.userCanDelete,
titleKey: undefined
}
};
// Middleware functions
this.req = req;
this.res = res;
this.next = next;
// This is used for "not found" messages that must be in the format
// "x not found" (for the body) and "x not found title" (for the title)
this.messageKeyPrefix = '';
// Defaults
options = Object.assign({
action: 'add',
method: 'GET',
id: undefined // only for edit/delete operations
}, options);
Object.assign(this, options);
// Shortcuts to common helpers, which also lets us override these with
// custom methods if appropriate
this.renderTemplate = render.template.bind(render, this.req, this.res);
this.renderResourceError = render.resourceError.bind(render, this.req, this.res);
this.renderPermissionError = render.permissionError.bind(render, this.req, this.res);
this.renderSigninRequired = render.signinRequired.bind(render, this.req, this.res);
this.getResourceErrorHandler = getResourceErrorHandler.bind(getResourceErrorHandler, this.req, this.res, this.next);
this.parseForm = forms.parseSubmission.bind(forms, this.req);
}
execute() {
let actions = Object.keys(this.actions);
if (actions.indexOf(this.action) == -1)
throw new Error('Did not recognize form action: ' + this.type);
if (typeof this.actions[this.action][this.method] != 'function')
throw new Error('No defined handler for this method.');
// Perform pre-flight checks (e.g., permission checks). Pre-flight checks
// are responsible for rendering failure/result messages, so no
// additional rendering will take place if any checks fail.
let mayProceed = true;
for (let check of this.actions[this.action].preFlightChecks) {
let result = Reflect.apply(check, this, []);
if (!result) {
mayProceed = false;
break; // First check to fail will be responsible for rendering error
}
}
if (!mayProceed)
return;
if (!this.actions[this.action].loadData)
Reflect.apply(this.actions[this.action][this.method], this, []); // Call appropriate handler
else {
// Asynchronously load data and show 404 if not found
Reflect.apply(this.actions[this.action].loadData, this, [])
.then(data => {
// If we have a permission check, only proceeds if it succeeds.
// If we don't have a permission check, proceed.
if (!this.actions[this.action].resourcePermissionCheck ||
Reflect.apply(this.actions[this.action].resourcePermissionCheck, this, [data]))
Reflect.apply(this.actions[this.action][this.method], this, [data]);
})
.catch(this.getResourceErrorHandler(this.messageKeyPrefix, this.id));
}
}
userIsSignedIn() {
if (!this.req.user) {
this.renderSigninRequired({
titleKey: this.actions[this.action].titleKey
});
return false;
} else
return true;
}
userIsTrusted() {
if (!this.req.user || !this.req.user.isTrusted) {
this.renderPermissionError({
titleKey: this.actions[this.action].titleKey,
detailsKey: "must be trusted",
});
return false;
} else
return true;
}
userCan(action, data) {
data.populateUserInfo(this.req.user);
if (action == 'edit' && data.userCanEdit)
return true;
else if (action == 'delete' && data.userCanDelete)
return true;
else {
this.renderPermissionError({
titleKey: this.actions[this.action].titleKey
});
return false;
}
}
userCanEdit(data) {
return this.userCan('edit', data);
}
userCanDelete(data) {
return this.userCan('delete', data);
}
// Adds a pre-flight check to all actions in provided array.
// If not defined, adds to all actions
addPreFlightCheck(actions, check) {
if (!actions)
actions = Object.keys(this.actions);
for (let action of actions)
this.actions[action].preFlightChecks.push(check);
}
}
AbstractBREADProvider.getDefaultRoutes = function(resource) {
// The default does not (yet) include a browse route.
// The code below parses the IDs in the route, so be careful adding
// non-standard patterns.
return {
add: {
path: `/new/${resource}`,
methods: ['GET', 'POST']
},
read: {
path: `/${resource}/:id`,
methods: ['GET']
},
edit: {
path: `/${resource}/:id/edit`,
methods: ['GET', 'POST']
},
delete: {
path: `/${resource}/:id/delete`,
methods: ['GET', 'POST']
}
};
};
// This registers default routes that are common for editable resources,
// following a standard pattern.
//
// resource -- the identifier used in URLs for the resource
// that is being configured.
//
// routes (optional) -- actions and associated Express routes that we want to
// set up. POST routes will only be created for add/edit/delete actions.
AbstractBREADProvider.bakeRoutes = function(resource, routes) {
let Provider = this;
if (!routes)
routes = this.getDefaultRoutes(resource);
function _bakeRoute(action, method, idArray) {
return function(req, res, next) {
let options = {
action,
method
};
// We always initialize each provider with the provided IDs,
// ready for use as object properties.
idArray.forEach(id => (options[id] = req.params[id]));
let provider = new Provider(req, res, next, options);
provider.execute();
};
}
for (let action in routes) {
// Extract variable placeholders
let idMatches = routes[action].path.match(/\/:(.*?)(\/|$)/g);
// Extract variable names
let idArray = idMatches ? idMatches.map(id => id.match(/\w+/)[0]) : [];
// Register router function for each specified method (GET, POST, etc.).
// The router methods like router.get() are lower case.
for (let method of routes[action].methods)
router[method.toLowerCase()](routes[action].path,
_bakeRoute(action, method, idArray));
}
return router;
};
module.exports = AbstractBREADProvider;