"use strict";
import inflect from "inflect";
import { JSONSerializer } from "./serializers";
import Route from "./route";
import { getOwner, setOwner } from "./containment";
const METHODS = {
DELETE: "delete"
};
const restActionMapper = new Map([
["index", "get"],
["show", "get"],
["create", "post"],
["update", "put"],
["destroy", "del"]
]);
const restPathMapper = new Map([
["index", "/"],
["show", "/:id"],
["create", "/"],
["update", "/:id"],
["destroy", "/:id"]
]);
/**
* Manages routing
*
* @class Router
* @constructor
* @param {Object} registry {{#crossLink "Registry"}}module registry{{/crossLink}}
*/
class Router {
constructor(registry) {
setOwner(this, registry);
const config = registry.lookup("config:main");
/**
* Contains the model and controller {{#crossLink "Loader"}}loaders{{/crossLink}}
*
* @property loader
* @type {Object}
*/
this.loader = {
controllers: registry.lookup("loader:controller"),
models: registry.lookup("service:model-manager").models
};
/**
* An optional namespace to place before all routes (e.g. /v1)
*
* @property namespacePrefix
* @type {String}
*/
this.namespacePrefix = config.namespace || "";
this._loadControllers();
}
/**
* Bind a set of routes to a namespace.
* Uses {{#crossLink "Router/_buildRoute:method"}}_buildRoute{{/crossLink}} to
* normalize the path
*
* @method namespace
* @param {String} namespace the namespace to bind to, with or without leading slash
* @param {Object[]} routes array of routes to bind to the namespace
* @since 0.9.0
*
* @example
* ```javascript
* Router.map(function () {
* this.namespace("/users/:userId", [
* { path: "/setProfileImage", using: "user:setImage", method: "post" }
* ])
* });
* ```
*/
namespace(namespace, routes) {
for (const route of routes) {
const fullPath = this._buildRoute(
this.namespacePrefix,
namespace,
route.path
);
this.route(fullPath, {
using: route.using,
method: route.method
});
}
}
/**
* Register a resource and wire up restful endpoints.
* Uses {{#crossLink "Router/_buildRoute:method"}}_buildRoute{{/crossLink}} to
* normalize the path and builds your 5 basic CRUD endpoints
*
* @method resource
* @param {String} name the resource name in singular form
* @param {Object} options resource mapping options
* @param {String} options.namespace mount the resource endpoint under a namespace
*
* @example
* ```javascript
* Router.map(function () {
* this.resource("user");
*
* // Optionally prefix this resource with a namespace
* this.resource("user", { namespace: "api" })
* });
* ```
*/
resource(name, options = {}) {
name = inflect.singularize(name);
const registry = getOwner(this);
let controller;
try {
controller = registry.lookup(`controller:${name}`);
} catch (err) {
controller = {};
for (const [action] of restActionMapper) {
controller[action] = registry.lookup(`controller:${name}.${action}`);
controller.__children = true;
}
}
for (const [action] of restActionMapper) {
let controllerInstance = controller;
let method = action;
if (controller.__children) {
controllerInstance = controller[action];
method = "model";
options.action = action;
}
this._mapControllerAction(name, controllerInstance, method, options);
}
}
/**
* Register a single route.
* Uses {{#crossLink "Router/_buildRoute:method"}}_buildRoute{{/crossLink}} to
* normalize the path
*
* @method route
* @param {String} path the route path (e.g. /foo/bar)
* @param {Object} options
* @param {String} options.using colon delimited controller method identifier
* @param {String} options.method http method
*
* @example
* ```javascript
* Router.map(function () {
* this.route("/user/foo", { using: "users:foo", method: "get" });
* });
* ```
*/
route(path, options) {
const app = getOwner(this).lookup("service:server");
const [controllerName, actionName] = options.using.split(":");
let action, controller;
try {
action = actionName;
controller = getOwner(this).lookup(`controller:${controllerName}`);
} catch (err) {
controller = getOwner(this).lookup(`controller:${controllerName}.${actionName}`);
action = "model";
}
let method = options.method;
const handlers = this._generateControllerHandlers(controller, action);
if (method === METHODS.DELETE) { method = "del"; }
app[method](path, handlers);
}
/**
* Consistently builds a route from a set of path segments using
* {{#crossLink "Route"}}Route{{/crossLink}}
*
* @method _buildRoute
* @private
* @return {Object} route object with path property
*/
_buildRoute() {
return new Route(...arguments);
}
/**
* generates main route handler plus pre and post hooks
*
* @private
* @method _generateControllerHandlers
* @param {Object} controller
* @param {String} action controller method
*
* @return {Array} handlers
*/
_generateControllerHandlers(controller, action) {
const controllerAction = controller[action];
const { hooks } = controller;
const handlers = [controllerAction.bind(controller)];
if (hooks) {
this._registerLegacyControllerHooks(controller, action, hooks, handlers);
} else if (controller.beforeModel || controller.afterModel) {
this._registerControllerHooks(controller, handlers);
}
return handlers;
}
/**
* Generates a path segment from a given resource name
*
* @private
* @method _getPathSegment
* @param resource
* @param action
*
* @returns {String} pathSegment (e.g. `:userId`)
*/
_getPathSegment(resource, action) {
const restPathSegment = restPathMapper.get(action);
const resourceSegmentString = `${resource}Id`;
return inflect.camelize(
restPathSegment.replace("id", resourceSegmentString)
);
}
/**
* loads controllers from the loader
*
* @private
* @method _loadControllers
*/
_loadControllers() {
const controllerLoader = getOwner(this).lookup("loader:controller");
const modelLoader = getOwner(this).lookup("loader:model");
const { modules: controllers } = controllerLoader;
const { modules: models } = modelLoader;
const registry = getOwner(this);
Object.keys(controllers).forEach(controller => {
const Klass = controllers[controller];
if (Object.keys(Klass).length) {
/* eslint-disable arrow-body-style */
const Klasses = Object.keys(Klass).map(action => ({
Klass: Klass[action],
name: action
}));
/* eslint-enable arrow-body-style */
Klasses.forEach(subClass => {
const instance = new subClass.Klass(getOwner(this), {
parent: controller
});
registry.register(`controller:${controller}.${subClass.name}`, instance);
});
} else {
const instance = new Klass(getOwner(this));
const instanceName = inflect.singularize(instance.name);
registry.register(`controller:${instanceName}`, instance);
}
});
Object.keys(models).forEach(model => {
const instanceName = inflect.singularize(model);
const serializer = this._lookupSerializer(instanceName);
registry.register(`serializer:${instanceName}`, serializer);
});
}
/**
* Attempts to lookup a serializer by 'name' in the module loader. If one exists
* it is instantiated and registered by 'name'. If one does not exist the
* default JSONSerializer is instantiated and registered.
*
* @method _lookupSerializer
* @private
* @param {String} name lowercase singular lookup name (e.g. "user")
* @return {Object} serializer instance
*/
_lookupSerializer(name) {
const serializerLoader = getOwner(this).lookup("loader:serializer");
if (!serializerLoader) {
return new JSONSerializer();
}
const { modules: serializers } = serializerLoader;
const Serializer = serializers[name];
let serializer;
if (Serializer) {
serializer = new Serializer();
} else {
serializer = new JSONSerializer();
}
return serializer;
}
/**
* maps a resource controller action and route
*
* @private
* @method _mapControllerAction
* @param {String} resource the resource name
* @param {Object} controller the resource controller
* @param {String} action the controller method
* @param {Object} options mapping options
*/
_mapControllerAction(resource, controller, action, options) {
const actionForPath = action === "model" ? options.action : action;
const app = getOwner(this).lookup("service:server");
const handlers = this._generateControllerHandlers(controller, action);
const method = restActionMapper.get(actionForPath);
const namespace = options.namespace || "";
const singularResource = inflect.singularize(resource);
const pluralResource = inflect.pluralize(singularResource);
const pathSegment = this._getPathSegment(singularResource, actionForPath);
const resourcePath = this._buildRoute(
this.namespacePrefix,
namespace,
pluralResource,
pathSegment
);
app[method](resourcePath, handlers);
}
/**
* Registers controller beforeModel and afterModel hooks
*
* @method _registerControllerHooks
* @private
* @param {Object} controller {{#crossLink "Controller"}}controller{{/crossLink}}
* @param {Array} handlers restify request handlers
*/
_registerControllerHooks(controller, handlers) {
if (controller.beforeModel) {
handlers.unshift(controller.beforeModel.bind(controller));
}
if (controller.afterModel) {
handlers.push(controller.afterModel.bind(controller));
}
}
/**
* Registers legacy controller before/after hooks
*
* @method _registerLegacyControllerHooks
* @private
* @param {Object} controller {{#crossLink "Controller"}}controller{{/crossLink}}
* @param {String} action controller action type (index, create, update, etc)
* @param {Array} handlers restify request handlers
*/
_registerLegacyControllerHooks(controller, action, hooks, handlers) {
const actionHooks = hooks[action];
if (actionHooks && actionHooks.before) {
handlers.unshift(actionHooks.before.bind(controller));
}
if (actionHooks && actionHooks.after) {
handlers.push(actionHooks.after.bind(controller));
}
}
/**
* configures router resources
*
* @static
* @param {Object} settings
* @param {Function} callback called with the router instance
* @return {undefined}
*/
static map(registry, callback) {
const router = new Router(registry);
callback.call(router);
return router;
}
}
export default Router;