@absolunet/ioc2.1.0

View on GitHub

http/services/Router.js

//--------------------------------------------------------
//-- Node IoC - HTTP - Services - Router
//--------------------------------------------------------

import __                from '@absolunet/private-registry';
import Route             from '../Route';


/**
 * @private
 * @typedef {object} GroupOptions
 * @property {string} [as] - Route name.
 * @property {string} [prefix] - Route path prefix.
 * @property {string} [namespace] - Controller namespace to use.
 * @memberof http
 */

/**
 * @private
 * @typedef {object} RouteAttributes
 * @property {string} [asPrefix] - The route name prefix.
 * @property {string} [method] - The route HTTP method.
 * @property {string} [path] - The route path.
 * @property {string|Function} [action] - The route action, either a closure or a controller action.
 * @memberof http
 */

/**
 * @private
 * @typedef {object} BaseResourceData
 * @property {string} name - The resource name.
 * @property {string} method - The resource HTTP method.
 * @memberof http
 */

/**
 * @private
 * @typedef {ResourceData} ResourceAction
 * @property {boolean} single - Indicates that the resource handle a single instance and not a collection.
 * @memberof http
 */

/**
 * @private
 * @typedef {object} ResourceData
 * @property {string} action - The resource action name.
 * @property {string} url - The resource URL.
 * @memberof http
 */

/**
 * The HTTP router.
 * It wraps Express route bootstrapping into a convenient system that handles injection and names controller actions.
 *
 * @memberof http.services
 * @hideconstructor
 */
class Router {

	/**
	 * Class dependencies: <code>['app', 'server', 'router.handler', 'router.route', 'router.controller']</code>.
	 *
	 * @type {Array<string>}
	 */
	static get dependencies() {
		return ['app', 'server', 'router.handler', 'router.route', 'router.controller'];
	}

	/**
	 * @inheritdoc
	 * @private
	 */
	init() {
		__(this).set('groups', []);
		__(this).set('expressRouter', null);
	}

	/**
	 * Register a new GET route with the router.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	get(path, action) {
		return this.addRoute('get', path, action);
	}

	/**
	 * Register a new POST route with the router.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	post(path, action) {
		return this.addRoute('post', path, action);
	}

	/**
	 * Register a new PUT route with the router.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	put(path, action) {
		return this.addRoute('put', path, action);
	}

	/**
	 * Register a new PATCH route with the router.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	patch(path, action) {
		return this.addRoute('patch', path, action);
	}

	/**
	 * Register a new DELETE route with the router.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	delete(path, action) {
		return this.addRoute('delete', path, action);
	}

	/**
	 * Register a new route responding to all verbs.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	any(path, action) {
		return this.addRoute('all', path, action);
	}

	/**
	 * Register a new route responding to all verbs.
	 *
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	all(path, action) {
		return this.any(path, action);
	}

	/**
	 * Register a fallback route that matches anything in the current scope.
	 *
	 * @param {string|Function} action - The route action.
	 * @returns {http.Route} The newly created route instance.
	 */
	fallback(action) {
		return this.any('*', action);
	}

	/**
	 * Create a redirection route.
	 * The redirection can either be permanent or temporary.
	 *
	 * @param {string} from - The path to redirect from.
	 * @param {string} to - The destination path to redirect on.
	 * @param {boolean} [permanent] - Indicates that the redirection is permanent.
	 * @returns {http.Route} The newly created route instance.
	 */
	redirect(from, to, permanent = false) {
		return this.any(from, this.withCorePrefix('RedirectController@handle'))
			.with('to', to)
			.with('permanent', permanent);
	}

	/**
	 * Create a permanent redirection route.
	 *
	 * @param {string} from - The path to redirect from.
	 * @param {string} to - The destination path to redirect on.
	 * @returns {http.Route} The newly created route instance.
	 */
	permanentRedirect(from, to) {
		return this.redirect(from, to, true);
	}

	/**
	 * Register a static path for files.
	 *
	 * @param {string} path - The path for static route serving.
	 * @param {string} folder - The folder where the static content is hosted.
	 * @returns {http.Route} The newly created route instance.
	 */
	static(path, folder) {
		return this.get(`${path}/:file`, this.withCorePrefix('StaticController@handle'))
			.where('file', '[^\\s]+')
			.with({ path, folder });
	}

	/**
	 * Get controller name with core prefix.
	 *
	 * @param {string} name - The controller name.
	 * @returns {string} The fully qualified controller name.
	 */
	withCorePrefix(name) {
		const { coreNamespace, namespaceSeparator } = this.controllers;

		return `${coreNamespace}${namespaceSeparator}${name}`;
	}

	/**
	 * Add a controller binding.
	 *
	 * @param {string} name - The controller name.
	 * @param {http.controllers.Controller} controller - The controller class.
	 * @returns {http.repositories.ControllerRepository} The controller repository instance.
	 */
	controller(name, controller) {
		return this.controllers.add(name, controller);
	}

	/**
	 * Create a controller group.
	 *
	 * @param {string} namespace - The controller namespace.
	 * @param {Function} group - The controller group closure.
	 * @returns {http.repositories.ControllerRepository} The controller repository instance.
	 */
	controllerGroup(namespace, group) {
		return this.controllers.group(namespace, group);
	}

	/**
	 * Create resource routes.
	 * Uses the same controller for each resource route with the proper action.
	 *
	 * @param {string} resource - The resource for which the routes should be created.
	 * @param {string} controller - The resource controller name that will handle requests.
	 * @param {Array<string>} [only] - Indicates the routes restrictions.
	 * @param {boolean} [apiOnly=false] - Indicates that only API routes should be considered.
	 * @returns {http.services.Router} The current router instance.
	 */
	resource(resource, controller, only = [], apiOnly = false) {
		this.getResourceMapping(resource, controller)
			.filter(({ name, api }) => {
				return (only.length === 0 || only.includes(name)) && (!apiOnly || api);
			})
			.forEach(({ name, method, url, action }) => {
				this.addRoute(method, url, action).name(`${resource}.${name}`);
			});

		return this;
	}

	/**
	 * Create API resource routes, without "create" and "edit" routes, which normally show form.
	 * Uses the same controller for each resource route with the proper action.
	 *
	 * @param {string} resource - The resource for which the routes should be created.
	 * @param {string} controller - The resource controller name that will handle requests.
	 * @param {Array<string>} [only=[]] - Indicates the routes restrictions.
	 * @returns {http.services.Router} The current router instance.
	 */
	apiResource(resource, controller, only = []) {
		return this.resource(resource, controller, only, true);
	}

	/**
	 * Create a route group.
	 *
	 * @param {http.GroupOptions} options - The group options.
	 * @param {Function} group - The group closure.
	 * @returns {http.services.Router} The current router instance.
	 */
	group(options, group) {
		const data = __(this).get('groups');
		const index = data.push(options) - 1;
		group(this, this.app);
		data.splice(index, 1);

		return this;
	}

	/**
	 * Add a route in the route repository.
	 *
	 * @param {string} method - The HTTP verb, such as "GET", "POST", "DELETE", etc.
	 * @param {string} path - The route path.
	 * @param {string|Function} action - The action that will handle the request.
	 * @returns {http.Route} The newly created route instance.
	 */
	addRoute(method, path, action) {
		const route = this.makeRoute({ method, path, action });
		this.routes.add(route);

		return route;
	}

	/**
	 * Create a new route instance.
	 *
	 * @param {http.RouteAttributes} attributes - The route attributes.
	 * @returns {http.Route} The newly created route instance.
	 */
	makeRoute(attributes) {
		const data = { asPrefix: '', path: '', action: '' };

		[...__(this).get('groups'), attributes].forEach((current) => {
			Object.entries(this.getRouteMapping(current))
				.forEach(([key, value]) => {
					data[key] = `${data[key] || ''}${value}`;
				});
		});

		const { method, action } = attributes;

		data.method = method;

		if (typeof action === 'function') {
			data.action = action;
		}

		return new Route(data);
	}

	/**
	 * Get route or route group mapping.
	 *
	 * @param {http.Route} route - The route instance.
	 * @returns {http.RouteAttributes} The route mapping.
	 */
	getRouteMapping(route) {
		return {
			asPrefix: this.getRouteAsPrefixMapping(route),
			path:     this.getRoutePathMapping(route),
			action:   this.getRouteActionMapping(route)
		};
	}

	/**
	 * Get route or route group asPrefix mapping.
	 *
	 * @param {http.Route} route - The route instance.
	 * @returns {string} The route name prefix.
	 */
	getRouteAsPrefixMapping({ as }) {
		return as || '';
	}

	/**
	 * Get route or route group path mapping.
	 *
	 * @param {http.Route} route - The route instance.
	 * @returns {string} The full route path.
	 */
	getRoutePathMapping({ prefix, path }) {
		const formattedPath = (prefix || path || '')
			.split('/')
			.filter((part) => {
				return Boolean(part);
			})
			.join('/');

		return formattedPath ? `/${formattedPath}` : '';
	}

	/**
	 * Get route or route group action mapping.
	 *
	 * @param {http.Route} route - The route instance.
	 * @returns {string} The route action name.
	 */
	getRouteActionMapping({ namespace, action }) {
		const formattedNamespace = (namespace || '')
			.replace(/(?<last>\w)$/u, `$<last>${this.controllers.namespaceSeparator}`);

		return formattedNamespace || (typeof action === 'string' ? action : '') || '';
	}

	/**
	 * Generate route binding in Express server.
	 *
	 * @returns {express.Router} The Express Router instance.
	 */
	generate() {
		if (!__(this).get('expressRouter')) {
			const router = this.server.getRouter();

			this.post('/allo', (request, response) => {
				response.send('bob');
			});
			this.routes.all().forEach((route) => {
				const { constraints, path, method } = route;

				router[method](this.resolvePath(path, constraints), (request, response) => {
					return this.routerHandler.handleRequest(route, request, response);
				});
			});

			router.all('*', this.routerHandler.handleRouteNotFound.bind(this.routerHandler));

			__(this).set('expressRouter', router);
		}

		return __(this).get('expressRouter');
	}

	/**
	 * Call the route handler.
	 *
	 * @param {string} path - The route path.
	 * @param {string} [method] - The HTTP method to use.
	 * @param {*} [request] - The request instance to use.
	 * @returns {Promise} The async process promise.
	 */
	call(path, method = 'get', request = {}) {
		return this.runMiddleware(path, { ...request, method });
	}

	/**
	 * Manually run middleware to programmatically process an internal request.
	 *
	 * @param {string} path - The route path.
	 * @param {request} request - The request instance to use.
	 * @returns {Promise<{code: number, data: *}>} The HTTP code result and the response body.
	 */
	runMiddleware(path, request) {
		const server = this.server.make().use(this.generate());

		return new Promise((resolve, reject) => {
			server.runMiddleware(path, request, this.routerHandler.getInternalCallResultHandler(resolve, reject));
		});
	}

	/**
	 * Call the route handler associated with the given name.
	 *
	 * @param {string} name - The route name.
	 * @param {object} [parameters={}] - The route parameters.
	 * @param {object|request} [request={}] - The request instance to use.
	 * @returns {Promise} The async process promise.
	 */
	callByName(name, parameters = {}, request = {}) {
		const route = this.routes.findByName(name);
		const { compiledPath: path, method } = route.compilePath(parameters);

		return this.call(path, method, request);
	}

	/**
	 * Get the resource mapping data.
	 *
	 * @param {string} resource - The resource from which to get the mapping.
	 * @param {string} controller - The controller name.
	 * @returns {Array<object>} The List of resource action mapping.
	 */
	getResourceMapping(resource, controller) {
		return this.getResourceActions().map((action) => {
			return {
				...action,
				...this.getResourceData(resource, controller, action.name)
			};
		});
	}

	/**
	 * Get single resource route data.
	 *
	 * @param {string} resource - The resource from which to get data.
	 * @param {string} controller - The controller name.
	 * @param {string} action - The controller action name.
	 * @returns {http.ResourceData} The resource data.
	 */
	getResourceData(resource, controller, action) {
		return {
			name:   action,
			method: this.getResourceActionMethod(action),
			url:    this.getResourceUrlMapping(resource, action),
			action: `${controller}@${action}`
		};
	}

	/**
	 * Get available resource actions.
	 *
	 * @returns {Array<{name: string, method: string, single: boolean}>} The resource actions.
	 */
	getResourceActions() {
		return [
			{ name: 'index',   method: 'get',    single: false, api: true,  suffix: '' },
			{ name: 'create',  method: 'get',    single: false, api: false, suffix: '/create' },
			{ name: 'store',   method: 'post',   single: false, api: true,  suffix: '' },
			{ name: 'show',    method: 'get',    single: true,  api: true,  suffix: '' },
			{ name: 'edit',    method: 'get',    single: true,  api: false, suffix: '/edit' },
			{ name: 'update',  method: 'patch',  single: true,  api: true,  suffix: '' },
			{ name: 'destroy', method: 'delete', single: true,  api: true,  suffix: '' }
		];
	}

	/**
	 * Get single resource action data.
	 *
	 * @param {string} action - The action name.
	 * @returns {http.ResourceAction} The resource action.
	 */
	getResourceAction(action) {
		return this.getResourceActions().find(({ name }) => {
			return name === action;
		});
	}

	/**
	 * Get single resource action method name.
	 *
	 * @param {string} action - The action name.
	 * @returns {string} The resource action HTTP method.
	 */
	getResourceActionMethod(action) {
		return (this.getResourceAction(action) || {}).method;
	}

	/**
	 * Get single resource action URL mapping.
	 *
	 * @param {string} resource - The resource name.
	 * @param {string} action - The resource action.
	 * @returns {string} The URL for the resource for the specific action.
	 */
	getResourceUrlMapping(resource, action) {
		const { single, suffix } = this.getResourceAction(action);

		let uri = resource;

		if (single) {
			uri = `${resource}/:${resource}`;
		}

		if (!suffix) {
			return uri;
		}

		return `${uri}${this.app.isBound('translator') ? this.app.make('translator').translate(suffix) : suffix}`;
	}

	/**
	 * Resolve Express path based on given path and parameter constraints.
	 *
	 * @param {string} path - The route path.
	 * @param {*} constraints - The constraints for route parameters.
	 * @returns {string} The resolved path.
	 */
	resolvePath(path, constraints) {
		return Object.entries(constraints)
			.reduce((string, [parameter, constraint]) => {
				return string.replace(new RegExp(`:${parameter}(?<slash>/?)`, 'u'), `:${parameter}${constraint ? `(${constraint})` : ''}`);
			}, path);
	}

	/**
	 * Get server instance.
	 *
	 * @returns {Express} The Express server instance.
	 */
	getServer() {
		return this.server.getInstance();
	}

	/**
	 * Route repository.
	 *
	 * @type {http.repositories.RouteRepository}
	 */
	get routes() {
		return __(this).get('router.route');
	}

	/**
	 * Controller repository.
	 *
	 * @type {http.repositories.ControllerRepository}
	 */
	get controllers() {
		return __(this).get('router.controller');
	}

}


export default Router;