halacious
Version:
A better HAL processor for Hapi
892 lines (756 loc) • 29 kB
JavaScript
var joi = require('joi');
var hapi = require('hapi');
var _ = require('lodash');
var fs = require('fs');
var path = require('path');
var hoek = require('hoek');
var swig = require('swig');
var extras = require('swig-extras');
var util = require('util');
var async = require('async');
var RepresentationFactory = require('./representation').RepresentationFactory;
var urlTemplate = require('url-template');
var Negotiator = require('negotiator');
var URITemplate = require('URIjs/src/URITemplate');
var url = require('url');
var URI = require('URIjs');
extras.useFilter(swig, 'markdown');
var re = /\{([^\{\}]+)\}|([^\{\}]+)/g;
function reach(object, path) {
var parts = path ? path.split('.') : [];
for (var i = 0; i < parts.length && !_.isUndefined(object); i++) {
object = object[parts[i]];
}
return object;
}
/**
* evaluates and flattens deep expressions (e.g. '/{foo.a.b}') into a single level context object: {'foo.a.b': value}
* so that it may be used by url-template library
* @param template
* @param ctx
* @return {{}}
*/
function flattenContext(template, ctx) {
var arr, result = {};
while ((arr = re.exec(template)) !== null) {
if (arr[1]) {
var value = reach(ctx, arr[1]);
result[arr[1]] = value && value.toString();
}
}
return result;
}
var optionsSchema = {
absolute: joi.boolean().default(false),
hostname: joi.string(),
port: joi.number().integer(),
protocol: joi.string(),
strict: joi.boolean().default(false),
relsPath: joi.string().default('/rels'),
relsAuth: joi.alternatives().try(joi.boolean().allow(false),joi.object()).default(false),
autoApi: joi.boolean().default(true),
apiPath: joi.string().default('/api'),
apiAuth: joi.alternatives().try(joi.boolean().allow(false),joi.object()).default(false),
apiServerLabel: joi.string(),
mediaTypes: joi.array().includes(joi.string()).single().default(['application/hal+json'])
};
/**
* Registers plugin routes and an "api" object with the hapi server.
* @param server
* @param opts
* @param next
*/
exports.register = function (server, opts, next) {
var settings = opts;
joi.validate(opts, optionsSchema, function (err, validated) {
if (err) throw err;
settings = validated;
});
var selection = settings.apiServerLabel ? server.select(settings.apiServerLabel) : server;
var internals = {};
// for tracking down namespaces
internals.byName = {};
internals.byPrefix = {};
// valid rel options
internals.relSchema = {
// the rel name, will default to file's basename if available
name: joi.string()
.required(),
// a path to the rel's documentation in html or markdown
file: joi.string()
.optional(),
// a short textual description
description: joi.string()
.optional(),
// returns the qualified name of the rel (including the namespace)
qname: joi.func()
.optional()
.default(function () {
return this.namespace ? util.format('%s:%s', this.namespace.prefix, this.name) : this.name;
})
};
// valid namespace options
internals.nsSchema = {
// the namespace name, will default to dir basename if available
name: joi.string()
.required(),
// a path to a directory containing rel descriptors. all rels will automatically be added
dir: joi.string()
.optional(),
// the namespace prefix for shorthand rel addressing (e.g. 'prefix:relname')
prefix: joi.string()
.optional()
.default(joi.ref('name')),
// a short description
description: joi.string()
.optional(),
// a map of rel objects, keyed by name
rels: joi.object()
.optional(),
// validates and adds a rel to the namespace
rel: joi.func()
.optional()
.default(function (rel) {
this.rels = this.rels || {};
if (_.isString(rel)) rel = { name: rel };
rel.name = rel.name || rel.file && path.basename(rel.file, path.extname(rel.file));
joi.validate(rel, internals.relSchema, function (err, value) {
if (err) throw err;
rel = value;
});
this.rels[rel.name] = rel;
rel.namespace = this;
return this;
}),
// synchronously scans a directory for rel descriptors and adds them to the namespace
scanDirectory: joi.func()
.optional()
.default(function (directory) {
var files = fs.readdirSync(directory);
files.forEach(function (file) {
this.rel({ file: path.join(directory, file) });
}, this);
return this;
})
};
internals.filter = function (request) {
return _.get(request.route.settings, 'plugins.hal', true);
};
/**
* Returns a list of all registered namespaces sorted by name
* @return {*}
*/
internals.namespaces = function () {
return _.sortBy(_.values(internals.byName), 'name');
};
/**
* Validates and adds a new namespace configuration
* @param namespace the namespace config
* @return {*} a new namespace object
*/
internals.namespaces.add = function (namespace) {
// if only dir is specified
namespace.name = namespace.name || namespace.dir && path.basename(namespace.dir);
// fail fast if the namespace isnt valid
joi.validate(namespace, internals.nsSchema, function (err, value) {
if (err) throw err;
namespace = value;
// would prefer to initialize w/ joi but it keeps a static reference to the value for some reason
namespace.rels = {};
});
if (namespace.dir) {
namespace.scanDirectory(namespace.dir);
}
// index and return
internals.byName[namespace.name] = namespace;
internals.byPrefix[namespace.prefix] = namespace;
return namespace;
};
/**
* Removes one or all registered namespaces. Mainly used for testing
* @param {String=} namespace the namespace to remove. a falsy value will remove all namespaces
*/
internals.namespaces.remove = function (namespace) {
var ns;
if (!namespace) {
internals.byName = {};
internals.byPrefix = {};
} else {
ns = internals.byName[namespace];
if (ns) {
delete internals.byName[namespace];
delete internals.byPrefix[namespace.prefix];
}
}
};
/**
* Looks up a specific namespace
* @param namespace
* @return {*}
*/
internals.namespace = function (namespace) {
return internals.byName[namespace];
};
/**
* Sorts and returns all rels by namespace
* @return {*}
*/
internals.rels = function () {
var rels = [];
_.values(internals.byName)
.forEach(function (ns) {
rels = rels.concat(_.values(ns.rels) || []);
});
return _.sortBy(rels, 'name');
};
/**
* Adds a new rel configuration to a namespace
* @param {String} namespace the namespace name
* @param rel the rel configuration
* @return the new rel
*/
internals.rels.add = function (namespace, rel) {
var ns = internals.byName[namespace];
if (!ns) throw new Error('Invalid namespace ' + namespace);
ns.rel(rel);
return ns.rels[rel.name];
};
/**
* Looks up a rel under a given namespace
* @param {String} namespace the namespace name
* @param {String} name the rel name
* @param {boolean} strict if the namespace is found but not the rel, throw an error rather than lazily create the rel
* @return {*} the rel or undefined if not found
*/
internals.rel = function (namespace, name, strict) {
var parts, ns, rel;
if (!name) {
// for shorthand namespace:rel notation
if (namespace.indexOf(':') > 0) {
parts = namespace.split(':');
ns = internals.byPrefix[parts[0]];
name = parts[1];
}
} else {
ns = internals.byName[namespace];
}
// namespace is valid, check for rel
if (ns) {
if (ns.rels[name]) {
// rel has been defined
rel = ns.rels[name];
} else if (!strict) {
// lazily create the rel
ns.rel({ name: name });
rel = ns.rels[name];
} else {
// could be a typo, fail fast to let the developer know
throw new Error('No such rel: "' + namespace + '"');
}
} else {
// could be globally qualified (e.g. 'self')
joi.validate({ name: namespace }, internals.relSchema, function (err, value) {
rel = value;
});
}
return rel;
};
/**
* Route handler for /rels
* @type {{handler: handler}}
*/
internals.namespacesRoute = function (relsAuth) {
return {
auth: relsAuth,
handler: function (req, reply) {
reply.view('namespaces', { path: req.path, namespaces: internals.namespaces() });
}
};
};
/**
* Route handler for /rels/{namespace}/{rel}
* @type {{handler: handler}}
*/
internals.relRoute = function (relsAuth) {
return {
auth: relsAuth,
handler: function (req, reply) {
var rel = internals.rel(req.params.namespace, req.params.rel);
if (!rel) return reply(hapi.error.notFound());
if (rel.file) {
fs.readFile(rel.file, function (err, data) {
reply.view('rel', { rel: rel, relData: data.toString() });
});
} else {
reply.view('rel', { rel: rel });
}
}
};
};
// see http://tools.ietf.org/html/draft-kelly-json-hal-06#section-8.2
internals.linkSchema = {
href: joi.alternatives([joi.string(), joi.func()])
.required(),
templated: joi.boolean()
.optional(),
title: joi.string()
.optional(),
type: joi.string()
.optional(),
deprecation: joi.string()
.optional(),
name: joi.string()
.optional(),
profile: joi.string()
.optional(),
hreflang: joi.string()
.optional()
};
internals.isRelativePath = function (path) {
return path && (path.substring(0, 2) === './' || path.substring(0, 3) === '../');
};
/**
* Resolves a name
* @param link
* @param relativeTo
*/
internals.link = function (link, relativeTo) {
relativeTo = relativeTo && relativeTo.split('?')[0];
link = _.isFunction(link) || _.isString(link) ? { href: link } : hoek.clone(link);
joi.validate(link, internals.linkSchema, function (err, value) {
if (err) throw err;
link = value;
});
if (relativeTo && internals.isRelativePath(link.href)) {
link.href = new URI(link.href).absoluteTo(relativeTo + '/')
.toString();
}
return link;
};
// keeps found routes in a cache
internals.routeCache = {};
/**
* Locates a named route. This feature may not belong here
* @param routeName
* @return {*}
*/
internals.locateRoute = function (routeName) {
var route, routes, i;
if (internals.routeCache[routeName]) {
return internals.routeCache[routeName].path;
}
routes = server.table()
.reduce(function (acc, conn) {
return acc.concat(conn.table);
}, []);
for (i = 0; i < routes.length; i++) {
route = routes[i];
if (route.settings.plugins.hal && route.settings.plugins.hal.name === routeName) {
internals.routeCache[routeName] = route;
return route;
}
}
};
/**
* Locates a named route and expands templated parameters
* @param routeId
* @param params
* @return String the expanded path to the named route
*/
internals.route = function (routeId, params) {
var route = server.lookup(routeId) || internals.locateRoute(routeId);
if (!route) throw new Error('No route found with id or name ' + routeId);
var href = _.template(route.path, { interpolate: /{([\s\S]+?)}/g })(params);
var query = hoek.reach(route.settings, 'plugins.hal.query');
return query ? href + query : href;
};
/**
* Returns the documentation link to a namespace
* @param namespace
* @return {*}
*/
internals.namespaceUrl = function (namespace) {
return [settings.relsPath, namespace.name].join('/');
};
/**
* Configures a representation with parameters specified by a hapi route config. The configuration object may
* include 'links', 'embedded', and 'prepare' properties.
* @param {Representation} rep the representation to configure
* @param {{}} config the config object
* @param callback
*/
internals.configureRepresentation = function configureRepresentation(rep, config, callback) {
var resolveHref = function (href, ctx) {
return _.isFunction(href) ? href(rep, ctx) : urlTemplate.parse(href)
.expand(flattenContext(href, ctx));
};
try {
var entity = rep.entity;
// shorthand prepare function
if (_.isFunction(config)) config = { prepare: config };
// configure links
_.forEach(config.links, function (link, rel) {
link = internals.link(link, rep.self.href);
link.href = resolveHref(link.href, entity);
rep.link(rel, link);
// grab query options
if (config.query) {
link.href += config.query;
}
});
/**
* Wraps callback functions to support next(rep) instead of next(null, rep)
* @param callback
* @return {Function}
*/
var wrap = function (callback) {
return function (err, result) {
if (err instanceof Error) {
callback(err);
} else {
callback(null, result || rep);
}
};
};
/**
* Looks for a toHal(representation, next) method on the entity. If found, it is called asynchronously. The method may modify the
* representation or pass back a completely new representation by calling next(newRep)
* @param callback
*/
var convertEntity = function (callback) {
if (_.isFunction(entity.toHal)) {
entity.toHal(rep, wrap(callback));
} else {
callback(null, rep);
}
};
/**
* Looks for an asynchronous prepare method for programmatic configuration of the outbound hal entity. As with
* toHal(), the prepare method can modify the existing rep or create an entirely new one.
* @param rep
* @param callback
*/
var prepareEntity = function (rep, callback) {
if (_.isFunction(config.prepare)) {
config.prepare(rep, wrap(callback));
} else {
callback(null, rep);
}
};
// configure embedded declarations. each rel entry is also a representation config object
async.each(Object.keys(config.embedded || {}), function (rel, cb) {
var embed = config.embedded[rel];
// assume that arrays should be embedded as a collection
if (!embed.path) {
throw new Error('Error in route ' + rep.request.path + ': "embedded" route configuration property requires a path');
}
var embedded = hoek.reach(entity, embed.path);
if (!embedded) return cb();
// force the embed array to be inialized. no self rel is necessary
if (_.isArray(embedded)) rep.embed(rel, null, []);
// force into an array for iterating
embedded = [].concat(embedded);
// embedded reps probably also shouldnt appear in the object payload
rep.ignore(embed.path);
async.each(embedded, function (item, acb) {
var link = internals.link(resolveHref(embed.href, { self: entity, item: item }), rep.self.href);
// create the embedded representation from the possibly templated href
var embeddedRep = rep.embed(rel, link, item);
embeddedRep = _.isArray(embeddedRep) ? embeddedRep : [embeddedRep];
// recursively process its links/embedded declarations
async.each(embeddedRep, function (e, bcb) {
configureRepresentation(e, embed, bcb);
}, acb);
}, cb);
}, function (err) {
if (err) return callback(err);
rep.ignore(config.ignore);
// cascade the async config functions
async.waterfall([
convertEntity,
prepareEntity
], callback);
});
} catch (e) {
callback(e);
}
};
/**
* Selects the media type based on the request's Accept header and a ranked ordering of configured
* media types.
* @param mediaTypes
* @param request
* @return {*}
*/
internals.getMediaType = function (mediaTypes, request) {
return new Negotiator(request).mediaType(_.isArray(mediaTypes) ? mediaTypes : [mediaTypes]);
};
/**
* Expands the url path to include protocol://server:port
* @param request
* @param path
* @param search
* @return {*}
*/
internals.buildUrl = function (request, path, search) {
return url.format({
host: request.headers.host,
hostname: settings.hostname || request.connection.info.host,
port: settings.port || request.connection.info.port,
pathname: path,
protocol: settings.protocol || request.connection.info.protocol,
search: search
});
};
/**
* Expands the query string template, if present, using query parameter values in the request.
* @param request
* @param queryTemplate
* @param { boolean } absolute whether the link should be expanded to include the server
* @return {*}
*/
internals.getRequestPath = function (request, queryTemplate, absolute) {
var uriTemplate;
var path = absolute ? internals.buildUrl(request, request.path) : request.path;
if (queryTemplate) {
uriTemplate = new URITemplate(path + queryTemplate);
return uriTemplate.expand(request.query);
}
return path;
};
/**
* Resolves a relative url. Borrowed from hapi
* @param request
* @param uri
* @param absolute
* @return {*}
*/
internals.location = function (request, uri, absolute) {
var isAbsolute = (uri.match(/^\w+\:\/\//));
var path = isAbsolute ? uri : (uri.charAt(0) === '/' ? '' : '/') + uri;
var search = null;
if (isAbsolute) {
path = uri;
} else {
var parts = uri.split('?');
path = (parts[0].charAt(0) === '/' ? '' : '/') + parts[0];
if (parts.length > 1) {
search = parts[1];
}
}
if (absolute) {
path = internals.buildUrl(request, path, search);
}
return path;
};
internals.successfulResponseCode = function (statusCode) {
return statusCode === 200 || statusCode === 201;
};
internals.isSourceEligible = function (source) {
return _.isObject(source) && !(_.isArray(source));
};
internals.isRequestEligible = function (request) {
// hapi 9/10 routes can be marked internal only
return !request.route.settings.isInternal && internals.filter(request);
};
internals.isResponseEligible = function (response) {
return response.variety === 'plain' && internals.successfulResponseCode(response.statusCode);
};
internals.shouldHalify = function (request) {
return internals.isRequestEligible(request) &&
internals.isResponseEligible(request.response) &&
internals.isSourceEligible(request.response.source);
};
/**
* A hapi lifecycle method that looks for the application/hal+json accept header and wraps the response entity into a
* HAL representation
* @param halacious
* @param settings
* @param request
* @param reply
*/
internals.preResponse = function (halacious, settings, request, reply) {
var rf, halConfig, entity, rep, self, location;
var mediaType = internals.getMediaType(settings.mediaTypes, request);
var absolute;
if (mediaType && internals.shouldHalify(request)) {
halConfig = request.route.settings.plugins.hal || {};
// all new representations for the request will be built by this guy
rf = new RepresentationFactory(halacious, request);
entity = request.response.source;
absolute = halConfig.absolute || settings.absolute;
// e.g. honor the location header if it has been set using response.created(...) or response.location(...)
location = request.response.headers.location;
self = location ? internals.location(request, location, absolute) : internals.getRequestPath(request, halConfig.query, absolute);
rep = rf.create(entity, self);
// asynchronously configure the rep and its children, then send the response
internals.configureRepresentation(rep, halConfig, function (err, rep) {
if (err) {
return reply(err);
}
// send back what they asked for
var response = reply(rep)
.type(mediaType)
.code(request.response.statusCode);
// avoid an undefined header
if (request.response.settings.location) response.location(request.response.settings.location);
response.hold();
// // copy headers
_.forEach(request.response.headers, function (value, key) {
if (key !== 'content-type') {
response.header(key, value);
}
});
response.send();
});
} else {
reply.continue();
}
};
/**
* Prepares a hal response with all root "api" handlers declared in the routing table. Api handlers are identified with
* the plugins.hal.api configuration settings. This function is exported for convenience if the developer wishes to
* define his or her own api handler in order to include metadata in the payload
*
* @param absolute
* @param rep
* @param next
*/
internals.apiLinker = function (absolute, rep, next) {
// grab the routing table and iterate
var req = rep.request;
var routes = req.server.table()
.reduce(function (acc, conn) {
return acc.concat(conn.table);
}, []);
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
var halConfig = route.settings.plugins.hal || {};
if (halConfig.api) {
var rel = halConfig.api;
var href = routes[i].path;
if (absolute) {
href = internals.buildUrl(rep.request, href);
}
// grab query options
if (halConfig.query) {
href += halConfig.query;
}
rep.link(rel, href);
}
}
next();
};
/**
* Creates an auto api route configuration
* @param absolute
* @param apiAuth
* @return {{auth: *, handler: handler, plugins: {hal: apiLinker}}}
*/
internals.apiRouteConfig = function (absolute, apiAuth) {
return {
auth: apiAuth,
handler: function (req, reply) {
reply({})
.type('application/hal+json');
},
plugins: {
hal: internals.apiLinker.bind(null, absolute)
}
};
};
/**
* Creates a redirector to redirect the browser from /api to /api/
* @param apiUrl
* @param apiAuth
* @return {{auth: *, handler: handler}}
*/
internals.apiRedirectConfig = function (apiUrl, apiAuth) {
return {
auth: apiAuth,
handler: function (req, reply) {
reply.redirect(apiUrl + '/');
}
};
};
/**
* Assigns a filter function to test routes before applying the hal interceptor.
* @param filterFn
*/
internals.setFilter = function (filterFn) {
joi.validate(filterFn, joi.func(), function (err) {
if (err) throw err;
internals.filter = filterFn;
});
};
internals.setUrlBuilder = function(urlBuilder) {
joi.validate(urlBuilder, joi.func(), function (err) {
if (err) throw err;
internals.buildUrl = urlBuilder;
});
};
var api = {
namespaces: internals.namespaces,
namespace: internals.namespace,
namespaceUrl: internals.namespaceUrl,
link: internals.link,
rels: internals.rels,
rel: function (namespace, name) {
return internals.rel(namespace, name, opts.strict);
},
resolve: internals.resolve,
route: internals.route,
apiLinker: internals.apiLinker,
filter: internals.setFilter,
urlBuilder: internals.setUrlBuilder
};
// hapi wont find the local swig without this
server.expose(api);
selection.ext('onPreResponse', internals.preResponse.bind(internals, api, settings));
if (settings.autoApi) {
// bind the API handler to api root + '/'. Ending with '/' is necessary for resolving relative links on the client
selection.route({
method: 'GET',
path: settings.apiPath + '/',
config: internals.apiRouteConfig(settings.absolute, settings.apiAuth)
});
// set up a redirect to api root + '/'
if (settings.apiPath.length > 0) {
selection.route({
method: 'GET',
path: settings.apiPath,
config: internals.apiRedirectConfig(settings.apiPath, settings.apiAuth)
});
}
}
server.after(function (server, next) {
if (_.isFunction(server.views)) {
server.log(['halacious', 'info'], 'Views support detected, installing documentation routes');
server.views({
engines: {
html: swig
},
path: path.join(__dirname, '../views'),
isCached: false
});
server.route({
method: 'get',
path: settings.relsPath,
config: internals.namespacesRoute(settings.relsAuth)
});
server.route({
method: 'get',
path: settings.relsPath + '/{namespace}/{rel}',
config: internals.relRoute(settings.relsAuth)
});
} else {
server.log(['halacious', 'info'], 'Views support not detected. Please install vision plugin for rel documentation');
}
next();
});
next();
};
exports.register.attributes = {
pkg: require('../package.json')
};
;