sails
Version:
API-driven framework for building realtime apps, using MVC conventions (based on Express and Socket.io)
581 lines (481 loc) • 22 kB
JavaScript
/**
* Module dependencies.
*/
var util = require('util');
var _ = require('@sailshq/lodash');
var flaverr = require('flaverr');
var detectVerb = require('../util/detect-verb');
/**
* Expose `bind` method.
*/
module.exports = bind;
/**
* Bind new route(s)
*
* @param {String|RegExp} path
* @param {String|Object|Array|Function} target
* @param {String} verb (optional)
* @param {Object} options (optional)
*
* @this {SJSRouter}
* @return {SJSApp}
*
* @api private
*/
function bind( /* path, target, verb, options */ ) {
var sails = this.sails;
var args = sanitize.apply(this, Array.prototype.slice.call(arguments));
var path = args.path;
var target = args.target;
var verb = args.verb;
var options = args.options;
// Don't allow paths with "length" as a route param, because Express chokes on it
if (path.match(/\/:length($|\/)/)) {
throw flaverr({name: 'userError', code: 'E_ROUTE_WITH_LENGTH'}, new Error('Failed to bind route: `'+ path +'`\n'+
'Routes which contain `/:length` in their address URL are not supported by Sails/Express (consider using `/:len`)'));
}
// Bind a list of multiple functions in order
if (_.isArray(target)) {
bindArray.apply(this, [path, target, verb, options]);
}
// Handle string redirects
// (to either public-facing URLs or internal routes)
else if (_.isString(target) && target.match(/^(https?:|\/)/)) {
bindRedirect.apply(this, [path, target, verb, options]);
}
// Otherwise if the target is a string, it must be an action.
else if (_.isString(target) || (_.isPlainObject(target) && (target.controller || target.action))) {
bindAction.apply(this, [path, target, verb, options]);
}
// Bind a middleware function directly
else if (_.isFunction(target)) {
bindFunction.apply(this, [path, target, verb, options]);
}
// If target is an object with a `target`, pull out the rest
// of the keys as route options and then bind the target.
else if (_.isPlainObject(target) && (target.target || target.fn)) {
var _target = target.target || target.fn;
// TODO -- replace _.merge() with a call to merge-dictionaries module?
options = _.merge(options, _.omit(target, 'target'));
bind.apply(this, [path, _target, verb, options]);
}
else {
// If we make it here, the router doesn't know how to parse the target.
//
// This doesn't mean that it's necessarily invalid though--
// so we'll emit an event informing any listeners that an unrecognized route
// target was encountered. Then hooks can listen to this event and act
// accordingly. This makes it easier to add functionality to Sails.
sails.emit('route:typeUnknown', {
path: path,
target: target,
verb: verb,
options: options
});
// Note that, in the future, it would be good to track emissions of "typeUnknown" to
// avoid logic errors that result in circular routes.
// (part of the effort to make a more friendly environment for custom hook developers)
}
// Makes `.bind()` chainable (sort of)
return sails;
}
/**
* Requests will be redirected to the specified string
* (which should be a URL or redirectable path.)
*
* @api private
*/
function bindRedirect(path, redirectTo, verb, options) {
var sails = this.sails;
bind.apply(this,[path, function(req, res) {
sails.log.verbose('Redirecting request (`' + path + '`) to `' + redirectTo + '`...');
res.redirect(redirectTo);
}, verb, options]);
}
/**
* Bind a previously-loaded action to a URL.
* (which should be a URL or redirectable path.)
*
* @api private
*/
function bindAction(path, target, verb, options) {
var self = this;
var sails = this.sails;
var actionIdentity;
try {
actionIdentity = self.getActionIdentityForTarget(target);
} catch (e) {
throw flaverr({name: e.name || 'sailsError', code: e.code || 'E_UNKNOWN_BIND_ERROR'}, new Error('Error attempting to bind `' + (verb || 'ALL') + ' ' + path + '` to ' + JSON.stringify(target) + ': ' + e.message));
}
if (_.isObject(target)) {
// Fold any other properties in the target into a shallow clone of the "options" dictionary
options = _.extend({}, options, _.omit(target, 'action'));
}
// If there's no loaded action with that identity, log a warning and continue.
if (!sails._actions[actionIdentity]) {
sails.log.warn('Ignored attempt to bind route (' + path + ') to unknown action ::', target);
return;
}
// Add "action" property to the route options, and set the _middlewareType property if the function doesn't already have one.
_.extend(options || {}, {action: actionIdentity, _middlewareType: (sails._actions[actionIdentity] && sails._actions[actionIdentity]._middlewareType || 'ACTION: ' + actionIdentity)});
// Loop through all of the registered action middleware, and find
// any that should apply to the action with the given identity.
var actionMiddlewareToRun = _.reduce(sails._actionMiddleware, function(memo, middlewareList, key) {
// Split the key into an array and sort it so that strings starting with '!' come first.
var targets = key.split(',').sort();
_.any(targets, function(target) {
// Remove any whitespace surrounding the target.
target = target.trim();
// If the target starts with a '!' (meaning that any actions matching it should _not_
// run the middleware), and the target matches, bust out of this loop early.
if (target[0] === '!') {
target = target.substr(1);
if (
// Does the target end in a `/*`, and the action identity matches the wildcard?
(target.slice(-2) === '/*' && ((actionIdentity.indexOf(target.slice(0,-1)) === 0) || actionIdentity === target.slice(0, -2)) ) ||
// Does the target match the action identity exactly?
(actionIdentity === target)
) {
// We found a matching target, so we can exit this loop.
return true;
}
}
// If the target doesn't start with a '!', it means we already got past all of the
// negative targets (since the targets are sorted alphabetically), so we can safely
// add any middleware that this action matches.
else {
if (
// If the registered action middleware key is '*'...
target === '*' ||
// Or ends in '/*' so that the current action identity matches the wildcard...
(target.slice(-2) === '/*' && ((actionIdentity.indexOf(target.slice(0,-1)) === 0) || actionIdentity === target.slice(0, -2)) ) ||
// Or matches the current action identity exactly...
(actionIdentity === target)
) {
// Then add the action middleware from this key to the list of middleware
// to run before the action.
memo = memo.concat(middlewareList);
// We found a matching target, so we can exit this loop.
return true;
}
}
// Check the next target.
return false;
});
// Keep on reducin'.
return memo;
}, []);
// Get a unique list of middleware, in case any were added more than once.
actionMiddlewareToRun = _.uniq(actionMiddlewareToRun);
// Bind each middleware to the identity.
_.each(actionMiddlewareToRun, function(middleware) {
// console.log('binding middleware', middleware.toString(), 'to path', verb + ' ' + path);
bind.apply(self, [path, middleware, verb, options]);
});
// Now, bind a function to the route which calls the specified action.
bind.apply(this,[path, function(req, res, next) {
// If the specified action doesn't exist in the internal actions dictionary.
// bail out early.
if (!_.isFunction(sails._actions[actionIdentity])) {
return next(new Error('Consistency violation: Request (' + req.method + ' ' + req.path + ') matched a route that is bound to action `' + actionIdentity + '`, but no such action has been registered. This never should have happened, because the route never should have been bound in the first place.'));
}
// Create a mock "next" function to catch unauthorized use of the third argument to route handlers.
var mockNext = function(err) {
if (err) {
return next(new Error('`next` (as in req,res,next) should never be called in an action function (but in action `' + actionIdentity + '`, it was!) It was called with an error: ' + (_.isError(err) ? err.stack : util.inspect(err, {
depth: null
}) + '') + ' Please use a method like `res.serverError()` or `res.badRequest()` instead.'));
}
return next(new Error('`next` (as in req,res,next) should never be called in an action function (but in action `' + actionIdentity + '`, it was!) It was called with no arguments. Please use a method like `res.ok()` or `res.json()` instead.'));
};//ƒ
try {
// Catch errors in async actions. See more notes about async route handlers in the `bindFunction` code below.
// > FUTURE: optimize by precomputing this constructor.name check
if (sails._actions[actionIdentity].constructor.name === 'AsyncFunction') {
// Call the action with the specified identity, passing in req and res, as well as `mockRes` to catch unauthorized
// use of `next` inside of end-user action code.
var promise = sails._actions[actionIdentity](req, res, mockNext);
promise.catch(function(e) {
// If we do catch an error, use `next` to let Express handle it correctly.
next(e);
});
}
// For synchronous actions, just call the function.
else {
// Call the action with the specified identity, passing in req and res, as well as `mockRes` to catch unauthorized
// use of `next` inside of end-user action code.
return sails._actions[actionIdentity](req, res, mockNext);
}
} catch(e) {
// If we do catch an error, use `next` to let Express handle it correctly.
return next(e);
}
}, verb, options]);
}
/**
* Recursively bind an array of targets in order
*
* TODO: Use a counter to prevent indefinite loops--
* only possible if a bad route is bound,
* but would still potentially be helpful.
*
* @api private
*/
function bindArray(path, target, verb, options) {
var self = this;
var sails = this.sails;
if (target.length === 0) {
sails.log.verbose('Ignoring empty array in `router.bind(' + path + ')`...');
} else {
// Bind each middleware fn
_.each(target, function(fn) {
bind.apply(self,[path, fn, verb, options]);
});
}
}
/**
* Attach middleware function to route.
*
* @api private
*/
function bindFunction(path, fn, verb, options) {
var sails = this.sails;
// Make sure (optional) options is a valid plain object ({})
// TODO -- replace _.isPlainObject with _.isObject && !_.isArray && !_.isFunction ?
// TODO -- if we're doing _.cloneDeep here, do we need it in all the places we do it in blueprints?
options = _.isPlainObject(options) ? _.cloneDeep(options) : {};
// Warn about no-longer-used blueprint request options.
if (_.intersection(_.keys(options), ['populate', 'skip', 'limit', 'sort', 'where']).length > 0) {
sails.log.debug('In route `' + verb + ' ' + path + ':');
sails.log.debug('The `populate`, `skip`, `limit`, `sort` and `where` route options are no longer supported in Sails 1.0.');
sails.log.debug('Instead, you can use a `parseBlueprintOptions` function to fully customize blueprint behavior for a route.');
sails.log.debug('See http://sailsjs.com/docs/reference/configuration/sails-config-blueprints#?using-parseblueprintoptions.');
sails.log.debug();
}
// Get the _middlewareType of the function.
var _middlewareType =
// If it was set on the function itself, use that.
fn._middlewareType ||
// Otherwise if options._middlewareType is set (probably because the function was defined inline
// in a call to `.bind()`), use that.
options._middlewareType ||
// Otherwise if the function has a name, use that.
('FUNCTION: ' + (fn.name || '<anonymous>'));
// Set the middleware type on the function. This can be useful for debugging if the same function
// was bound in different contexts (like different actions).
fn._middlewareType = _middlewareType;
// Remove any _middlewareType property from the options. It's done its job, and we don't need
// it to get merged into req.options.
// delete options._middlewareType;
// Log info about the bound route in SILLY mode.
sails.log.silly('Binding route :: ', verb || '', path, _middlewareType);
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: simplify away the unnecessary function declarations below and inline the logic instead.
// (will make it much clearer what's going on; and any minimal performance impact will be in the form of gains)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/**
* `router:route`
*
* Create a closure that emits the `router:route` event each time the route is hit
* before actually triggering the target function.
*
* NOTE: Modifications to route path parameters (i.e. `req.params`) or to `req.options`
* must be made here, since their values can change not only on a per-request, but
* also a per-route basis.
*/
var enhancedFn = function routeTargetFnWrapper(req, res, next) {
// Set req.options, using `options` to supply default values.
req.options = _.merge({}, options || {}, req.options || {});
// This event can be tapped into to take control of
// (synchronous) logic that should be run before each bound
// route handler function runs.
sails.emit('router:route', {
req: req,
res: res,
next: next,
options: options,
fn: fn
});
// Trigger original route handler function.
//
// > Note that, if it is an async function, then we also attach a handler to `.catch()`
// > its return value (which will be a promise) in order to handle rejections in the same
// > way we handle exceptions that are thrown synchronously (mainly for the purpose of being able to use async/await)
// > (https://trello.com/c/UdK9ooJ3/108-es7-async-await-in-core-sniff-request-handler-function-to-see-if-it-s-an-async-function-if-so-then-grab-the-return-value-from-th)
try {
if (fn.constructor.name === 'AsyncFunction') {
// FUTURE: benchmark this and, if tangible enough, allow configuration to be used to hard-code functions
// one way or the other. (Frankly, seems like we could just forcefully swap all request handling functions
// over to be async functions -- but that'd be kind of a big change and I'd rather wait for a later release
// unless we can prove that that'd definitely be a 100% backwards compatible change)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var promise = fn(req, res, next);
promise.catch(function(e) {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note that async+await+bluebird+Node 8 errors are not necessarily "true" Error instances,
// as per _.isError() anyway (see https://github.com/node-machine/machine/commits/6b9d9590794e33307df1f7ba91e328dd236446a9).
// So if we want improve the stack trace here, we'd have to be a bit more relaxed and tolerate
// these sorts of "errors" directly as well (by tweezing out the `cause`, which is where the
// original Error lives.)
//
// Note: This is now taken care of automatically by flaverr.parseError()
// (The implementation of this "tweezing" is in the default serverError
// response handler though.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
next(e);
// (Note that we don't do `return next(e)` here. That's on purpose--
// to avoid sending the wrong idea to you, dear reader)
});
}
else {
fn(req, res, next);
}
} catch (e) { return next(e); }
};
/**
* Wrap a regex route in a helper function that pulls out regex params
*
* Example: for route: 'r|/\\d+/(.*)/(.*)$|foo,bar', the two parenthesized
* groups would be pulled out as req.params[0] and req.params[1] by Express;
* the regexRouteWrapper would then map them to req.params['foo'] and req.params['bar']
*
* @param {array} params List of params to apply to the req.params object
* @return {Function} A middleware function
*/
var regexRouteWrapper = function(params) {
return function(req, res, next) {
// Apply the regex route params
params.forEach(function(param, index) {
req.params[param] = req.params[index];
});
// Call enhancedFn (which is just defined above)
enhancedFn(req, res, next);
};
};
/**
* Wrap a route in a helper function that first checks whether the URL matches
* any of a set of regexes, and if so, skips the defined handler.
*
* @param {array} regexes Array of regexes to match the URL against
* @param {Function} fn Middleware function to run if URL does NOT match regexes
* @return {Function} A middleware function
*/
var skipRegexesWrapper = function(regexes, fn) {
// Remove anything that's not a regex
regexes = _.compact(regexes.map(function(regex) {
if (regex instanceof RegExp) {
return regex;
}
sails.log.warn('Invalid regex "' + regex + '" supplied to skipRegexesWrapper; ignoring.');
return undefined;
}));
return function(req, res, next) {
// Check for matches
for (var i = 0; i < regexes.length; i++) {
if (req.url.match(regexes[i])) {
// If we find one, bail out
return next();
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// TODO: Need to double-check on this, but shouldn't this call `enhancedFn`, instead of just `fn`?
// If so, then we can just make that change. Otherwise, we need to do more here.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Otherwise continue with the handler
return fn(req, res, next);
};
};
// If verb is not specified, default to CRUD methods.
// You can still explicitly route to "all /path" if you want ALLLLlllll the things.
var targetVerb = verb || ['get', 'put', 'post', 'delete', 'patch'];
// Ensure targetVerb is an array of lowercased verbs.
if (!Array.isArray(targetVerb)) {targetVerb = [targetVerb.toLowerCase()];}
else {
targetVerb = _.map(targetVerb, function(verb) { return verb.toLowerCase(); });
}
// Function to actually bind
var targetFn;
// Regex to check if the route is...a regex.
var regExRoute = /^r\|(.*)\|(.*)$/;
// Perform the check
var matches = path.match(regExRoute);
// If it *is* a regex, create a RegExp object that Express can bind,
// pull out the params, and wrap the handler in regexRouteWrapper
if (matches) {
path = new RegExp(matches[1]);
var params = matches[2].split(',');
targetFn = regexRouteWrapper(params);
}
// Otherwise just bind enhancedFn
else {
targetFn = enhancedFn;
}
// If options.skipRegex is specified, make sure it's an array
if (options.skipRegex) {
if (!Array.isArray(options.skipRegex)) {
options.skipRegex = [options.skipRegex];
}
}
// Otherwise just make it an empty array
else {
options.skipRegex = [];
}
// For GET routes ending in pattern vars, default `skipAssets` to true.
if (_.isString(path) && path.match(/\:[^\/]+\/?$/) && _.isUndefined(options.skipAssets) && _.contains(targetVerb, 'get')) {
options.skipAssets = true;
}
// If "skipAssets" option is true, add the skipAssets regex
// to the options.skipRegex array
if (options.skipAssets) {
options.skipRegex.push(sails.LOOKS_LIKE_ASSET_RX);
}
// If we have anything in the options.skipRegex array, wrap
// the target function again.
if (options.skipRegex.length) {
targetFn = skipRegexesWrapper(options.skipRegex, targetFn);
}
// Loop through the verbs we want to bind
targetVerb.forEach(function(verb) {
// Bind the function to the private router
sails.router._privateRouter[verb](path, targetFn);
// Emit an event to make hooks aware that a route was bound
// This allows hooks to handle routes directly if they want to-
// e.g. with Express, the handler for this event looks like:
// sails.hooks.http.app[verb || 'all'](path, target);
sails.emit('router:bind', {
path: path,
target: targetFn,
verb: verb,
options: options,
originalFn: fn
});
});
}
/**
* Sanitize the arguments to `sails.router.bind()`
*
* @returns {Object} sanitized arguments
* @api private
*/
function sanitize(path, target, verb, options) {
options = options || {};
// If trying to bind '*', that's probably not what was intended, so fix it up
path = path === '*' ? '/*' : path;
// If route has an HTTP verb (e.g. `get /foo/bar`, `put /bar/foo`, etc.) parse it out,
var detectedVerb = detectVerb(path);
// then prune it from the path
path = detectedVerb.original;
// Keep track of parsed verb so we know if it was specified later
options.detectedVerb = detectedVerb;
// If a verb override was not specified,
// use the detected verb from the string route
if (!verb) {
verb = detectedVerb.verb;
}
return {
path: path,
target: target,
verb: verb,
options: options
};
}