node-barefoot
Version:
Barefoot makes code sharing between browser and server reality. Write your application once and run it on both ends of the wire.
568 lines (519 loc) • 19.3 kB
JavaScript
/** Mixin: Barefoot.APIAdapter.Server
* The server mixin for the <APIAdapter> takes the specified apiRoutes and does
* two things:
* * Create RESTful API routes with Express.JS, callable via HTTP
* * Map server-side API calls to the correct local callback
*
* The creation of the Express.JS routes is done on initialization. The mapping
* of server-side API calls is executed during runtime.
*
* Function Mapping:
* The following matrix describes which function of <APIAdapter.Server> is
* related to which task:
*
* > + REST API + Local API +
* > +------------------------+----------+-----------+
* > processCallbacks | X | X |
* > createExpressJsCallback | X | |
* > createExpressJsRoute | X | |
* > urlRegexp | | X |
* > matchRoute | | X |
* > addRoute | X | X |
* > createRouteFactories | X | X |
* > dispatchLocalApiCall | | X |
* > sync | | X |
* > +------------------------+----------+-----------+
*/
var _ = require('underscore')
, winston = require('winston')
, httpMethods = require('methods')
, methodMap = {
'create': 'post'
, 'update': 'put'
, 'patch': 'patch'
, 'delete': 'delete'
, 'read': 'get'
}
, debug = require('debug')('barefoot:server:apiadapter');
/** Function: toString
* String representation of this module.
*/
function toString() {
return 'APIAdapter.Server';
}
/** PrivateFunction: processCallbacks
* This function is used to run a callback function or an array of stacked
* callback functions which are registered for an API route.
*
* Parameters:
* (Object) namedRouteParameters - If a route contains named parameters
* (stuff like ":id", ":name"), this object
* should contain these values. They are
* passed as arguments to each callback
* function.
* (Object) data - The data argument contains any larger data amount. Stuff
* like the body of a POST request in example.
* (Object) req - ExpressJS request object is needed for creating scope
* objects for callback execution
* (Object) res - ExpressJS response object is needed for creating the scope
* object for calling the handler functions.
* (Function) successHandler - A function which is injected as the first
* argument when executing the callback
* (Function) errorHandler - A function which is injected as the second
* argument when executing the callback
* (Function)/(Array) callbacks - An API function which should be executed.
* If you pass an array with functions, each
* function gets executed as it is stacked
* upon the array. Calling success will
* proceed, error will stop execution.
* Make sure you call one of them or your
* code will go nuts.
*/
function processCallbacks(namedRouteParameters, data, req, res, successHandler
, errorHandler, callbacks) {
debug('processing callbacks (url: `%s`, namedRouteParameters: `%s`,' +
' data: `%j`)', req.url, namedRouteParameters, data);
var callbackScope = {
app: req.app
, req: req
}
, handlerScope = {
app: req.app
, req: req
, res: res
}
, callbackArguments = _.union(
successHandler.bind(handlerScope)
, errorHandler.bind(handlerScope)
, namedRouteParameters
, data
)
, index = -1
, executeCallback = function executeCallback(callback) {
callback.apply(callbackScope, callbackArguments);
}
, runner;
if(!_.isArray(callbacks)) {
runner = function() { executeCallback(callbacks); };
} else {
var finalSuccessHandler = callbackArguments[0]
, stackedSuccessHandler = function stackedSuccessHandler() {
index += 1;
if(index < callbacks.length) {
executeCallback(callbacks[index]);
} else {
finalSuccessHandler.apply(handlerScope, arguments);
}
};
callbackArguments[0] = stackedSuccessHandler;
runner = stackedSuccessHandler;
}
try {
runner();
} catch(err) {
winston.log('error', 'API callback caused exceptional error', {
source: toString()
, apiRoute: req.originalUrl
, err: err.toString() || err
, stack: err.stack || undefined
});
errorHandler.call(handlerScope, err);
}
}
/** Function: createExpressJsCallback
* Encapsulates given callback function or an array with stacked callback
* functions and prepares it so it can be registered as an express js route.
*
* The two functions successHandler and errorHandler are passed to the callback
* on runtime as the first two arguments.
*
* Parameters:
* (Function) successHandler - A function which is injected as the first
* argument when executing the callback
* (Function) errorHandler - A function which is injected as the second
* argument when executing the callback
* (Function)/(Array) callbacks - An API function which should be executed.
* If you pass an array with functions, each
* function gets executed as it is stacked
* upon the array.
*
* Returns:
* (Function) to be registered with express js.
*
* See also:
* * <processCallbacks>
*/
function createExpressJsCallback(successHandler, errorHandler, callbacks) {
debug('creating express.js callback');
return function handler(req, res) {
debug('express.js callback called for url `%s`', req.url);
processCallbacks(
_.values(req.params)
, req.body
, req
, res
, successHandler
, errorHandler
, callbacks);
};
}
/** PrivateFunction: createExpressJsRoute
* Creates an Express.JS request handlers.
*
* It takes an object containing route URI's like "/contacts" or
* "/contacts/:name" as key and callback functions as values.
* Using the passed Express.JS route-creation-function "binder" (app.post,
* app.put etc.), an Express.JS route is created.
*
* Each route callback is wrapped into an Express.JS specific handler function
* connected to the route URI. Combined with the Express.JS body parser
* middleware, the first argument of your callback will contain the body of the
* request. If your route defines any parameters ("/contacts/:id"), the specific
* values are passed as function arguments. If the request specifies any query
* parameters ("/contacts?message=test&limit=1"), you'll find an object literal
* containing all these key-value pairs at the end of your callback arguments
* list.
*
* Beside the return value of the callback, the wrapper function sends
* automatically an HTTP OK (200) to the request creator. If the callback throws
* an error object, that object is inspected for an "httpStatusCode" property.
* If present, that status code is delivered to the requestor. If not, an HTTP
* Internal Server Error (500) is sent.
*
* For a set of errors with predefined HTTP status codes, see <Barefoot.Errors>.
*
* Parameters:
* (Object) url - URL route to bind
* (Function)/(Array) callbacks - An API function which should be executed.
* If you pass an array with functions, each
* function gets executed as it is stacked
* upon the array.
* (Function) expressJsMethod - A function of Express.JS like app.get etc.
* (Object) app - The Express.JS app
*
* See also:
* * <Barefoot.Errors>
*/
function createExpressJsRoute(url, callbacks, expressJsMethod, app) {
debug('creating express.js route for url `%s`', url);
var self = this
, expressJsHandler = createExpressJsCallback(
function success(apiResult, httpStatusCode) {
httpStatusCode = httpStatusCode || 200;
this.res.send(httpStatusCode, apiResult);
}
, function error(err) {
var message
, stack
, httpStatusCode = 500;
if(!_.isUndefined(err)) {
if(_.has(err, 'httpStatusCode')) {
httpStatusCode = err.httpStatusCode;
}
message = err.toString() || err;
stack = err.stack || undefined;
}
this.res.send(httpStatusCode, message);
winston.log('error', 'API callback stopped with error', {
source: self.toString()
, apiRoute: url
, err: message
, stack: stack
});
}
, callbacks
, app);
expressJsMethod.call(app, url, expressJsHandler);
}
/** PrivateFunction: urlRegexp
* Takes a route URL with possible placeholders like "/contacts/:contactid" and
* prepares a RegEx for later pattern matching when trying to resolve a route
*
* Thanks to:
* * https://github.com/visionmedia/express/blob/master/lib/utils.js#L277
*
* Parameters:
* (String) url - The url pattern to create the regex of
* (Array) keys - An array which will contain all identified placeholder
* names afterwards
* (Boolean) sensitive - Create Regex case sensitive?
* (Boolean) strict - Create Regex in strict mode?
*
* Returns:
* (Regexp)
*/
function urlRegexp(url, keys, sensitive, strict) {
if (url.toString() === '[object RegExp]') { return url; }
if (Array.isArray(url)) { url = '(' + url.join('|') + ')'; }
url = url
.concat(strict ? '' : '/?')
.replace(/\/\(/g, '(?:/')
.replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?(\*)?/g, function(
_, slash, format, key, capture, optional, star){
keys.push({ name: key, optional: !! optional });
slash = slash || '';
return '' +
(optional ? '' : slash) +
'(?:' +
(optional ? slash : '') +
(format || '') +
(capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' +
(optional || '') +
(star ? '(/*)?' : '');
})
.replace(/([\/.])/g, '\\$1')
.replace(/\*/g, '(.*)');
return new RegExp('^' + url + '$', sensitive ? '' : 'i');
}
/** PrivateFunction: extractParams
* Takes a match object of a regex execution and extracts the parameters
* identified by keys. All values are returned inside an array at the end.
*
* Thanks to:
* * https://github.com/visionmedia/express/blob/master/lib/router/route.js#L50
* Paremters:
* (Object) match - Resulting match object of an executed Regexp
* (Object) keys - Keys to extract
* (Object) params - An object which will contain all extracted parameters
* from url, if a route matched.
*/
function extractParams(match, keys, params) {
params = params || {};
for(var i = 1, l = match.length; i < l; ++i) {
var key = keys[i - 1];
var val = 'string' === typeof match[i] ?
decodeURIComponent(match[i]) :
match[i];
if(key) {
params[key.name] = val;
} else {
params.push(val);
}
}
}
/** PrivateFunction: matchRoute
* This function takes an HTTP method and a URL. It tries to match any API route
* contained in apiRoutes. If a route is found, the routes regex is used to
* extract any parameters from the URL. These parameters are contained in the
* params argument afterwards.
*
* The actual matched route gets returned if found.
*
* Thanks to:
* * https://github.com/visionmedia/express/blob/master/lib/router/route.js#L50
*
* Parameters:
* (String) method - An HTTP method verb
* (String) url - The URL to match a route for
* (Object) apiRoutes - An object containing all api routes too look after
* (Object) params - An object which will contain all extracted parameters
* from url, if a route matched.
*
* Returns:
* (Object) containing all information about the matched api route. Dont
* forget that the params argument will contain any extracted
* parameters from the url!
*/
function matchRoute(method, url, apiRoutes, params) {
debug('matching route method %s with url %s', method, url);
var routes = apiRoutes[method]
, matchedRoute;
for(var routeUrl in routes) {
var route = routes[routeUrl]
, keys = route.keys
, match = route.regexp.exec(url);
if(match) {
matchedRoute = route;
extractParams(match, keys, params);
break;
}
}
debug('matched route `%j`', matchedRoute);
return matchedRoute;
}
/** PrivateFunction: addRoute
* The functions generated by <createRouteFactories> use this function to create
* an actual api route.
*
* To accomplish this, this creates a concrete Express.JS route which can be
* reached via REST HTTP. To make the route callable locally, it gets saved into
* the apiRoutes variable.
*
* Parameters:
* (String) method - The HTTP method for this route
* (String) url - The URL for this route. Gets prepared with <prepareAPIUrl>
* (Function)/(Array) callbacks - An API function which should be executed.
* If you pass an array with functions, each
* function gets executed as it is stacked
* upon the array.
*/
function addRoute(method, url, callbacks) {
debug('adding route (method: `%s`, url: `%s`)', method, url);
var urlParamKeys = []
, regexp = urlRegexp(url, urlParamKeys, true, true);
if(_.isUndefined(this.apiRoutes)) { this.apiRoutes = {}; }
if(_.isUndefined(this.apiRoutes[method])) { this.apiRoutes[method] = {}; }
createExpressJsRoute.call(
this, url, callbacks, this.app[method], this.app
);
this.apiRoutes[method][url] = {
callback: callbacks
, regexp: regexp
, keys: urlParamKeys
};
}
/** Function: createRouteFactories
* This is called by the constructor of <APIAdapter> and is heavily inspired by
* the fancy Express.JS Application API.
*
* It creates a function for each available HTTP method and makes it available
* through the <APIAdapter> itself.
*
* These functions then can be used to create an APIAdapter route.
*
* Example:
* > apiAdapter.get('/myroute', function myCallback(success) { success(); });
* > apiAdapter.post('/myroute', function myCallback(success) { success(); });
* > // any HTTP verb :)
*
* DELETE Routes:
* Since "delete" is a reserved word in JavaScript, you can use "del" as an
* alias when creating a DELETE route.
*/
function createRouteFactories() {
var self = this;
_.each(httpMethods, function(httpMethod) {
self[httpMethod] = function(url, callback) {
addRoute.call(self, httpMethod, url, callback);
};
});
if(_.has(self, 'delete')) {
self.del = self['delete'];
}
}
/** Function: dispatchLocalApiCall
* This is the core where server side API callbacks are dispatched. It is called
* by Barefoots <sync> replacement.
*
* It tries to match the given URL with a registered route for the given
* httpMethod. If found, the route callback(s) is/are invoked using
* <processCallbacks>.
*
* If the data argument contains any information, that stuff gets passed to the
* callback. If the options argument contains a success or error element, these
* functions are called by the callback in case of success or failure.
*
* Example Call:
* > dispatchLocalApiCall('post', '/contacts', { name: foo }, {
* > success: function() { console.log('yay!'); }
* > , error: function() { console.log('nay :('); }
* > });
*
* Parameters:
* (String) httpMethod - An HTTP verb. Necessary to match a registered
* route.
* (String) url - The URL of the API callback to dispatch
* (Object) data - Optional. Contains information which should be passed to
* the callback. (Example: Contact information which should
* be saved.)
* (Object) options - Should contain an error and success callback function.
*/
function dispatchLocalApiCall(httpMethod, url, data, options) {
debug('dispatching local api call (httpMethod: `%s`, url: `%s`)'
, httpMethod, url);
var self = this
, params = {}
, matchedRoute = matchRoute(httpMethod, url, this.apiRoutes, params);
options = options || {};
if(_.isUndefined(matchedRoute)) {
if(_.has(options, 'error')) {
options.error(new Error('Could not resolve API route: ' + url));
} else {
winston.log('info', 'Could not resolve API route', {
source: toString()
, apiRoute: url
});
}
}
var successHandler = function successHandler(apiResult) {
if(_.has(options, 'success')) {
options.success(apiResult);
} else {
winston.log('info', 'No success callback defined', {
source: self.toString()
, apiRoute: url
});
}
}
, errorHandler = function errorHandler(err) {
var message
, stack;
if(!_.isUndefined(err)) {
message = err.toString() || err;
stack = err.stack || undefined;
}
if(_.has(options, 'error')) {
options.error(err);
} else {
winston.log('info', 'No error callback defined', {
source: toString()
, apiRoute: url
});
}
winston.log('error', 'API callback stopped with error', {
source: self.toString()
, apiRoute: url
, err: message
, stack: stack
});
};
processCallbacks(
_.values(params)
, data
, this.req
, {}
, successHandler
, errorHandler
, matchedRoute.callback);
}
/** Function: sync
* During startup on the server, this function replaces Backbones own sync
* implementation to shortcut "local" API calls.
*
* Instead going the detour over an AJAX request, this implementation of sync
* calls the API callback directly by resolving the models URL with the present
* apiRoutes.
*
* If method is not equal to read or delete, the return value of the toJSON
* function of the given model is passed to the API callback.
*
* Parameters:
* (String) method - A method (create, update, patch, delete or read) which
* will be used to resolve the models URL properly.
* (Backbone.Model) model - The model which wants to be synced. Can also be
* an instance of Backbone.Collection.
* (Object) options - Should contain at least a success callback. Any api
* callback result will be delivered as argument of the
* success function.
*/
function sync(method, model, options) {
var url = options.url || _.result(model, 'url')
, httpMethod = methodMap[method]
, data;
debug('syncing (method: `%s`, httpMethod: `%s`, url: `%s`, model: `%j`)'
, method, httpMethod, url, model);
if(_.isUndefined(url)) {
throw new Error('No url present for syncing!', model, options);
}
if(method !== 'read' && method !== 'delete') {
data = model.toJSON();
}
this.dispatchLocalApiCall(httpMethod, url, data, options);
}
module.exports = {
createExpressJsCallback: createExpressJsCallback
, createRouteFactories: createRouteFactories
, dispatchLocalApiCall: dispatchLocalApiCall
, sync: sync
, toString: toString
};