denali-js/core

View on GitHub
lib/runtime/router.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import * as ware from 'ware';
import { IncomingMessage, ServerResponse } from 'http';
import { pluralize } from 'inflection';
import { fromNode } from 'bluebird';
import * as createDebug from 'debug';
import Errors from './errors';
import Route from './route';
import Request from './request';
import DenaliObject from '../metal/object';
import { lookup } from '../metal/container';
import ConfigService from './config';
import { Constructor } from '../utils/types';
import Action from './action';
import {
  find,
  forEach,
  castArray
 } from 'lodash';

const debug = createDebug('denali:router');

export interface RoutesCache {
  GET: Route[];
  POST: Route[];
  PUT: Route[];
  PATCH: Route[];
  DELETE: Route[];
  HEAD: Route[];
  OPTIONS: Route[];
  [method: string]: Route[];
}

export interface MiddlewareFn {
  (req: IncomingMessage, res: ServerResponse, next: Function): void;
}

export interface ResourceOptions {
  /**
   * Should routes for related resources be generated? If true, routes will be
   * generated following the JSON-API recommendations for relationship URLs.
   *
   * @see {@link http://jsonapi.org/recommendations/#urls-relationships|JSON-API URL
   * Recommendatiosn}
   */
  related?: boolean;
  /**
   * A list of action types to _not_ generate.
   */
  except?: string[];
  /**
   * A list of action types that should be the _only_ ones generated.
   */
  only?: string[];
}

export interface RouterDSL {
  get(pattern: string, action: string, params?: {}): void;
  post(pattern: string, action: string, params?: {}): void;
  put(pattern: string, action: string, params?: {}): void;
  patch(pattern: string, action: string, params?: {}): void;
  delete(pattern: string, action: string, params?: {}): void;
  head(pattern: string, action: string, params?: {}): void;
  options(pattern: string, action: string, params?: {}): void;
  resource(resourceName: string, options?: ResourceOptions): void;
}

/**
 * The Router handles incoming requests, sending them to the appropriate
 * action. It's also responsible for defining routes in the first place - it's
 * passed into the `config/routes.js` file's exported function as the first
 * argument.
 *
 * @package runtime
 * @since 0.1.0
 */
export default class Router extends DenaliObject implements RouterDSL {

  /**
   * The cache of available routes.
   */
  routes: RoutesCache = {
    GET: [],
    POST: [],
    PUT: [],
    PATCH: [],
    DELETE: [],
    HEAD: [],
    OPTIONS: []
  };

  /**
   * The internal generic middleware handler, responsible for building and
   * executing the middleware chain.
   */
  protected middleware: any = (<() => any>ware)();

  get config() {
    return lookup<ConfigService>('service:config');
  }

  /**
   * Helper method to invoke the function exported by `config/routes.js` in the
   * context of the current router instance.
   *
   * @since 0.1.0
   */
  map(fn: (router: Router) => void): void {
    debug('mapping routes');
    fn(this);
  }

  /**
   * Takes an incoming request and it's response from an HTTP server, prepares
   * them, runs the generic middleware first, hands them off to the appropriate
   * action given the incoming URL, and finally renders the response.
   */
  async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
    let serverConfig = this.config.get('server');
    let request = new Request(req, serverConfig);
    try {

      debug(`[${ request.id }]: ${ request.method.toUpperCase() } ${ request.path }`);

      // Middleware
      await fromNode((cb) => this.middleware.run(request, res, cb));

      // Find the matching route
      debug(`[${ request.id }]: routing request`);
      let routes = this.routes[request.method];
      if (routes) {
        /* tslint:disable-next-line prefer-for-of */
        for (let i = 0; i < routes.length; i += 1) {
          request.params = routes[i].match(request.path);
          if (request.params) {
            request.route = routes[i];
            break;
          }
        }
      }
      // Handle 404s
      if (!request.route) {
        let availableRoutes =  routes && routes.map((r) => r.spec);
        debug(`[${ request.id }]: ${ request.method } ${ request.path } did match any route. Available ${ request.method } routes:\n${ availableRoutes.join(',\n') || 'none' }`);
        let error = new Errors.NotFound('Route not recognized');
        error.meta = { availableRoutesForMethod: routes || [] };
        throw error;
      }

      // Create our action to handle the response
      let action: Action = new request.route.action();

      // Run the action
      debug(`[${ request.id }]: running action`);
      await action.run(request, res);

    } catch (error) {
      await this.handleError(request, res, error);
    }
  }

  /**
   * Takes a request, response, and an error and hands off to the generic
   * application level error action.
   */
  protected async handleError(request: Request, res: ServerResponse, error: Error) {
    request.params = request.params || {};
    request.params.error = error;
    let ErrorAction = lookup<Constructor<Action>>('action:error');
    let errorAction = new ErrorAction();
    return errorAction.run(request, res);
  }

  /**
   * Add the supplied middleware function to the generic middleware stack that
   * runs prior to action handling.
   *
   * @since 0.1.0
   */
  use(middleware: MiddlewareFn): void {
    this.middleware.use(middleware);
  }

  /**
   * Add a route to the application. Maps a method and URL pattern to an
   * action, with optional additional parameters.
   *
   * URL patterns can use:
   *
   * * Dynamic segments, i.e. `'foo/:bar'` * Wildcard segments, i.e.
   *   `'foo/*bar'`, captures the rest of the URL up to the querystring
   * * Optional groups, i.e. `'foo(/:bar)'`
   *
   * @since 0.1.0
   */
  route(method: string, rawPattern: string, actionPath: string, params?: any) {
    method = method.toUpperCase();
    // Ensure leading slashes
    let normalizedPattern = rawPattern.replace(/^([^/])/, '/$1');
    // Remove hardcoded trailing slashes
    normalizedPattern = normalizedPattern.replace(/\/$/, '');
    // Ensure optional trailing slashes
    normalizedPattern = `${ normalizedPattern }(/)`;
    // Add route
    let ActionClass = lookup<Constructor<Action>>(`action:${ actionPath }`);
    let route = new Route(normalizedPattern);
    route.actionPath = actionPath;
    route.action = ActionClass;
    route.additionalParams = params;
    if (!route.action) {
      throw new Error(`No action found at ${ actionPath }`);
    }
    this.routes[method].push(route);
  }

  /**
   * Returns the URL for a given action. You can supply a params object which
   * will be used to fill in the dynamic segements of the action's route (if
   * any).
   */
  urlFor(actionPath: string, data: any): string | boolean {
    let actionEntry = lookup<Constructor<Action>>(`action:${ actionPath }`, { loose: true });
    if (actionEntry === false) {
      return false;
    }

    let action = actionEntry; // because TS won't narrow the type in the forEach below
    let route: Route;
    forEach(this.routes, (routes) => {
      route = find(routes, { action });
      return !route; // kill the iterator if we found the match
    });

    return route && route.reverse(data);
  }

  /**
   * Shorthand for `this.route('get', ...arguments)`
   *
   * @since 0.1.0
   */
  get(rawPattern: string, actionPath: string, params?: any): void {
    this.route('get', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('post', ...arguments)`
   *
   * @since 0.1.0
   */
  post(rawPattern: string, actionPath: string, params?: any): void {
    this.route('post', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('put', ...arguments)`
   *
   * @since 0.1.0
   */
  put(rawPattern: string, actionPath: string, params?: any): void {
    this.route('put', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('patch', ...arguments)`
   *
   * @since 0.1.0
   */
  patch(rawPattern: string, actionPath: string, params?: any): void {
    this.route('patch', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('delete', ...arguments)`
   *
   * @since 0.1.0
   */
  delete(rawPattern: string, actionPath: string, params?: any): void {
    this.route('delete', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('head', ...arguments)`
   *
   * @since 0.1.0
   */
  head(rawPattern: string, actionPath: string, params?: any): void {
    this.route('head', rawPattern, actionPath, params);
  }

  /**
   * Shorthand for `this.route('options', ...arguments)`
   *
   * @since 0.1.0
   */
  options(rawPattern: string, actionPath: string, params?: any): void {
    this.route('options', rawPattern, actionPath, params);
  }

  /**
   * Create all the CRUD routes for a given resource and it's relationships.
   * Based on the JSON-API recommendations for URL design.
   *
   * The `options` argument lets you pass in `only` or `except` arrays to
   * define exceptions. Action names included in `only` will be the only ones
   * generated, while names included in `except` will be omitted.
   *
   * Set `options.related = false` to disable relationship routes.
   *
   * If no options are supplied, the following routes are generated (assuming a
   * 'books' resource as an example):
   *
   *   * `GET /books`
   *   * `POST /books`
   *   * `GET /books/:id`
   *   * `PATCH /books/:id`
   *   * `DELETE /books/:id`
   *   * `GET /books/:id/:relation`
   *   * `GET /books/:id/relationships/:relation`
   *   * `PATCH /books/:id/relationships/:relation`
   *   * `POST /books/:id/relationships/:relation`
   *   * `DELETE /books/:id/relationships/:relation`
   *
   * See http://jsonapi.org/recommendations/#urls for details.
   *
   * @since 0.1.0
   */
  resource(resourceName: string, options: ResourceOptions = {}): void {
    let plural = pluralize(resourceName);
    let collection = `/${ plural }`;
    let resource = `${ collection }/:id`;
    let relationship = `${ resource }/relationships/:relation`;
    let related = `${ resource }/:relation`;

    if (!options.related) {
      options.except = [ 'related', 'fetch-related', 'replace-related', 'add-related', 'remove-related' ].concat(options.except);
    }

    let hasWhitelist = Boolean(options.only);
    options.only = castArray(options.only);
    options.except = castArray(options.except);

    /**
     * Check if the given action should be generated based on the
     * whitelist/blacklist options
     */
    function include(action: string) {
      let whitelisted = options.only.includes(action);
      let blacklisted = options.except.includes(action);
      return !blacklisted && (
        (hasWhitelist && whitelisted) ||
        !hasWhitelist
      );
    }

    [
      [ 'list', 'get', collection ],
      [ 'create', 'post', collection ],
      [ 'show', 'get', resource ],
      [ 'update', 'patch', resource ],
      [ 'destroy', 'delete', resource ],
      [ 'related', 'get', related ],
      [ 'fetch-related', 'get', relationship ],
      [ 'replace-related', 'patch', relationship ],
      [ 'add-related', 'post', relationship ],
      [ 'remove-related', 'delete', relationship ]
    ].forEach((routeTemplate: [ string, string, string ]) => {
      let [ action, method, url ] = routeTemplate;
      if (include(action)) {
        let routeMethod = <(url: string, action: string) => void>this[method];
        routeMethod.call(this, url, `${ plural }/${ action }`);
      }
    });
  }

  [methodName: string]: any;

  /**
   * Enables easy route namespacing. You can supply a method which takes a
   * single argument that works just like the `router` argument in your
   * `config/routes.js`, or you can use the return value just like the router.
   *
   *   router.namespace('users', (namespace) => {
   *     namespace.get('sign-in');
   *   });
   *   // or ...
   *   let namespace = router.namespace('users');
   *   namespace.get('sign-in');
   */
  namespace(namespace: string, fn: (wrapper: RouterDSL) => void): void {
    let router = this;
    if (namespace.endsWith('/')) {
      namespace = namespace.slice(0, namespace.length - 1);
    }
    // tslint:disable:completed-docs
    let wrapper: RouterDSL = {
      get(pattern: string, actionPath, params) {
        router.route('get', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      post(pattern: string, actionPath, params) {
        router.route('post', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      put(pattern: string, actionPath, params) {
        router.route('put', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      patch(pattern: string, actionPath, params) {
        router.route('patch', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      delete(pattern: string, actionPath, params) {
        router.route('delete', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      head(pattern: string, actionPath, params) {
        router.route('head', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      options(pattern: string, actionPath, params) {
        router.route('options', `${ namespace }/${ pattern.replace(/^\//, '') }`, actionPath, params);
      },
      resource(resourceName: string, options: ResourceOptions) {
        router.resource.call(this, resourceName, options);
      }
    };
    // tslint:enable:completed-docs
    if (fn) {
      fn(wrapper);
    }
  }

}