Home Manual Reference Source

src/routes/Route.js

import KoaRouter from 'koa-router';
import chalk from 'chalk';
import { RateLimit } from 'koa2-ratelimit';

import ErrorApp from '../utils/ErrorApp';
import StatusCode from '../utils/StatusCode';
import { deepCopy, isArray } from '../utils/utils';
import { generateDoc } from '../utils/docGenerator';
import RouteDecorators from './RouteDecorators';

export default class Route {
  /**
   * @type {boolean}
   * @desc if true it will log which route are mount and which are not
   */
  static displayLog = true;
  /**
   * @type {StatusCode}
   */
  static StatusCode = StatusCode;

  /**
   * @typedef {Object} BeforeRouteParams
   * @property {string} path the path at which the route will be available.
   * @property {ParamsMethodDecorator} options
   * @property {function} call the fonction to call when route match, this is automaticaly add by route decorator
   */

  /**
   * @typedef {Object} PostParamsFilter
   * @property {ParamMiddlewareFunction[]} __func an array of functions which provides "middleware" functions that will be applied to the corresponding parameter one by one, in order to validate or transform it
   * @property {boolean} __force whether the parameter is required or not.
   */

  /**
   * @typedef {function} ParamMiddlewareFunction
   * @param {*} elem the element the function will act upon
   * @param {Route} [route] the element's current route
   * @param {{ctx: KoaContext, body:Object, keyBody:string}} [context] the element's context
   * @return {*} transformedParam the parameter, after being manipulated by the function
   */

  /**
   * @typedef {Object} RouteParams
   * @property {Koa} app the Koa application
   * @property {string} prefix a prefix which will be preppended before every route's paths
   * @property {Route[]} routes an array containing all the mounted Routes
   * @property {Model[]} [models] an array containing all of the app's models
   * @property {string} [model] the name of the route's own model
   * @property {disable} [boolean] whether the route should be disabled
   *
   */

  /**
   * @typedef {function} Decorator
   * @return { }
   */

  /**
   * @external {KoaContext} http://koajs.com/#api
   */

  /**
   * @external {Koa} http://koajs.com/#application
   */

  /**
   * @param {RouteParams} params the route's parameters
   */
  constructor({ app, prefix, routes, models, model, disable }) {
    /**
     * @type {Koa}
     * @version 1.0.0
     * @desc the main Koa application
     */
    this.app = app;
    /**
     * @type {string}
     * @version 1.0.0
     * @desc the route's prefix
     */
    this.prefix = prefix;
    /**
     * @type {Route[]}
     * @version 1.0.0
     * @desc an array composed of all the availble routes in the application
     */
    this.allRoutesInstance = routes;
    /**
     * @type {Model[]}
     * @version 1.0.0
     * @desc an array of all the models available in the application
     */
    this.models = models;
    /**
     * @type {boolean}
     * @desc whether the route should be disabled. disabled routes cannot be called.
     */
    this.disable = disable != null ? disable : this.disable;
    /**
     * @type {function[]}
     * @desc the route's registered middlewares
     */
    this.middlewares = this.middlewares || [];
    if (this.models && model) {
      /**
       * @type {Model|undefined}
       * @desc the route's own model
       */
      this.model = this.models[model];
    }
    /**
     * @type {KoaRouter}
     * @desc the underlying koa router for this particular route
     */

    this.koaRouter = new KoaRouter();
    /**
     * @ignore
     */
    this.privateKeyInParamsRoute = ['__force', '__func'];
    // This Variable are set by RouteDecorators
    this.routes;
    this.routeBase;
  }

  /**
   * @access public
   * @desc mounts the tagged function as a GET route.
   * @param {ParamsMethodDecorator} params the route's parameters
   * @return {Decorator}
   */
  static Get = RouteDecorators.Get;

  /**
   * @access public
   * @desc mounts the tagged function as a POST route.
   * @param {ParamsMethodDecorator} params the route's parameters
   * @return {Decorator}
   */
  static Post = RouteDecorators.Post;

  /**
   * @access public
   * @desc mounts the tagged function as a PUT route.
   * @param {ParamsMethodDecorator} params the route's parameters
   * @return {Decorator}
   */
  static Put = RouteDecorators.Put;

  /**
   * @access public
   * @desc mounts the tagged function as a PATCH route.
   * @param {ParamsMethodDecorator} params the route's parameters
   * @return {Decorator}
   */
  static Patch = RouteDecorators.Patch;

  /**
   * @access public
   * @desc mounts the tagged function as a DELETE route.
   * @param {ParamsMethodDecorator} params the route's parameters
   * @return {Decorator}
   */
  static Delete = RouteDecorators.Delete;

  /**
   * @access public
   * @desc used to set some parameters on an entire class.The supported parameters are middlewares, disable, and routeBase.
   * @return {Decorator}
   * @param {ParamsClassDecorator} params the route's parameters
   */
  static Route = RouteDecorators.Route;

  /**
   * logs a message, but only if the route's logs are set to be displayed.
   *
   * accepts several parameters
   */
  log(str, ...args) {
    if (Route.displayLog) {
      // eslint-disable-next-line
      console.log(str, ...args);
    }
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc Registers the route and makes it callable once the API is launched.
   *       the route will be called along with the middlewares that were registered in the decorator.
   *
   *       you will usually not need to call this method yourself.
   */
  mount() {
    if (this.disable !== true) {
      for (const type in this.routes) {
        // eslint-disable-line
        for (const route of this.routes[type]) {
          const routePath = `/${this.prefix}/${this.routeBase}/${route.path}`
            .replace(/[/]{2,10}/g, '/')
            .replace(/[/]$/, '');
          route.options.routePath = routePath;
          route.options.type = type;
          if (!route.options.disable) {
            this.log(chalk.green.bold('[Mount route]'), `\t${type}\t`, routePath);
            this.koaRouter[type](routePath, ...this._use(route));
            generateDoc(this, route);
          } else {
            this.log(chalk.yellow.bold('[Disable Mount route]\t'), type, routePath);
          }
        }
      }
    } else {
      this.log(chalk.yellow.bold(`Routes "${this.routeBase}" of class ${this.constructor.name} are't add`));
    }
  }

  // ************************************ MIDDLEWARE *********************************
  /**
   *@ignore
   */
  _use(infos) {
    const { options = {} } = infos;
    const { middlewares = [] } = options;

    const middlewaresToAdd = [this._beforeRoute(infos)];
    middlewaresToAdd.push(...this.middlewares); // add middlewares of the class
    middlewaresToAdd.push(...middlewares); // add middlewares of the specific route
    this.addRateLimit(middlewaresToAdd, infos);
    middlewaresToAdd.push(infos.call.bind(this));

    return middlewaresToAdd;
  }

  /**
   *@ignore
   */
  getRateLimit(option, routePath, type) {
    option.interval = RateLimit.RateLimit.timeToMs(option.interval);
    return RateLimit.middleware({
      prefixKey: `${type}|${routePath}|${option.interval}`,
      ...option,
    });
  }

  /**
   * if a decorator has a rateLimit property, it will add the rate limiting mechanism to the route,
   * with a unique ID for each route in order to differentiate the various routes.
   *
   * You should not need to call this method directly.
   * @version 1.0.0
   * @param {function[]} middlewares the array of currently registered middlewares for the given route
   * @param {{options:{rateLimit:Object,routePath:string,type:string}}} params the route's parameters
   */
  addRateLimit(middlewares, { options }) {
    const { rateLimit, routePath, type } = options;

    if (rateLimit) {
      if (isArray(rateLimit)) {
        for (const elem of rateLimit) {
          middlewares.push(this.getRateLimit(elem, routePath, type));
        }
      } else {
        middlewares.push(this.getRateLimit(rateLimit, routePath, type));
      }
    }
  }

  // beforeRoute
  /**
   *@ignore
   */
  _beforeRoute(infos) {
    return async (ctx, next) => await this.beforeRoute(ctx, infos, next);
  }

  /**
   * @desc a member which can be overriden, which will always be executed before the route is accessed
   * @param {KoaContext} ctx Koa's context object
   * @param {BeforeRouteParams} params an object containing all route parameters
   * @param {function} next the next middleware in the chain
   */
  async beforeRoute(ctx, { options }, next) {
    await this._mlTestAccess(ctx, options);
    this._mlParams(ctx, options);
    if (next) {
      await next();
    }
  }

  /**
   *@ignore
   */
  async _mlTestAccess(ctx, { accesses }) {
    if (isArray(accesses) && accesses.length) {
      for (const access of accesses) {
        if (await access(ctx)) return true;
      }
      this.throwForbidden(null, true);
    }
    if (isArray(this.accesses) && this.accesses.length) {
      for (const access of this.accesses) {
        if (await access(ctx)) return true;
      }
      this.throwForbidden(null, true);
    }

    return true;
  }

  /**
   *@ignore
   */
  _mlParams(ctx, { bodyType, queryType }) {
    if (bodyType) {
      ctx.request.bodyOrigin = deepCopy(ctx.request.body);
      ctx.request.bodyChanged = this._mlTestParams(ctx, ctx.request.body, bodyType);
      ctx.request.body = ctx.request.bodyChanged;
    }
    if (queryType) {
      ctx.request.queryOrigin = deepCopy(ctx.request.query || {});
      ctx.request.queryChanged = this._mlTestParams(ctx, ctx.request.query, queryType);
      ctx.request.query = ctx.request.queryChanged;
    }
  }

  /**
   *@ignore
   */
  _mlTestParams(ctx, body, type) {
    type.test(body);
    if (type.error || type.errors) {
      this.throwBadRequest(type.errors || type.error);
    }
    return type.value;
  }

  // ************************************ !MIDDLEWARE *********************************

  /**
   *@desc retrieves the context's body, if the request has one.
   *@version 1.0.0
   *@param {KoaContext} ctx koa's context object
   *@param {boolean} [original=false] if set to true, the function will return the body before it is filtered by the param decorator.
   *                                  otherwise, it will return the filtered and transformed body.
   */
  body(ctx, original = false) {
    return original ? ctx.request.bodyOrigin : ctx.request.bodyChanged;
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc retrieves the query params in a GET request
   * @param {KoaContext} ctx koa's context object
   * @return {Object.<string, *>}
   */
  queryParam(ctx, original = false) {
    return original ? ctx.request.queryOrigin : ctx.request.queryChanged;
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc sets the response's body (with a message + data field) and status.
   * @param {KoaContext} ctx koa's context object
   * @param {number} [status] the HTTP status code to end the request with
   * @param {*} [data] the data to be yielded by the requests
   * @param {string} [message] the message to be yielded by the request
   * @return { }
   */
  send(ctx, status = 200, data, message) {
    ctx.body = ctx.body || {}; // add default body
    ctx.status = status;
    // Do not remove this test because if status = 204 || 304, node will remove body
    // see _hasBody on
    // https://github.com/nodejs/node/blob/master/lib/_http_server.js#L235-L250
    if (ctx.body) {
      if (data != null) {
        ctx.body.data = data;
      }
      if (message != null) {
        ctx.body.message = message;
      }
      ctx.body.date = Date.now();
    }
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc same as {@link send}, but automatically sets the status to 200 OK
   * @param {KoaContext} ctx koa's context object
   * @param {*} [data] the data to be yielded by the requests
   * @param {string} [message] the message to be yielded by the request
   * @return { }
   */
  sendOk(ctx, data, message) {
    return this.send(ctx, Route.StatusCode.ok, data, message);
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc same as {@link send}, but automatically sets the status to 201 CREATED
   * @param {KoaContext} ctx koa's context object
   * @param {*} [data] the data to be yielded by the requests
   * @param {string} [message] the message to be yielded by the request
   * @return { }
   */
  sendCreated(ctx, data, message) {
    return this.send(ctx, Route.StatusCode.created, data, message);
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc replies with an empty body, yielding 204 NO CONTENT as the status
   * @param {KoaContext} ctx koa's context object
   * @return { }
   */
  sendNoContent(ctx) {
    return this.send(ctx, Route.StatusCode.noContent);
  }

  /**
   * @access public
   * @version 1.0.0
   * @desc throws a formated error to be caught.
   * @param {number} status the error's HTTP status StatusCode
   * @param {string | object} [error] the error(s) to be yielded by the request
   * @param {boolean} translate indicates whether the message should be translated or not
   * @throws {ErrorApp} thrown error.
   * @return { }
   */
  throw(status, error, translate = false) {
    throw new ErrorApp(status, error, translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link throw}, but automatically sets the status to 400 BAD REQUEST
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Bad request"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @return { }
   */
  throwBadRequest(error, translate = false) {
    return this.throw(Route.StatusCode.badRequest, error || 'Bad request', translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link throw}, but automatically sets the status to 401 UNAUTHORIZED
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Unauthorized"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @return { }
   */
  throwUnauthorized(error, translate = false) {
    return this.throw(Route.StatusCode.unauthorized, error || 'Unauthorized', translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link throw}, but automatically sets the status to 403 FORBIDDEN
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Forbidden"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @return { }
   */
  throwForbidden(error, translate = false) {
    return this.throw(Route.StatusCode.forbidden, error || 'Forbidden', translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link throw}, but automatically sets the status to 404 NOT FOUND
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Not found"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @return { }
   */
  throwNotFound(error, translate = false) {
    return this.throw(Route.StatusCode.notFound, error || 'Not found', translate);
  }

  /**
   * @access public
   * @desc checks a condition. If it evaluates to false, throws a formated error to be caught.
   * @param {boolean} condition if set to false; assert will fail and throw.
   * @param {number} status the error's HTTP status StatusCode
   * @param {string | object} [error] the error(s) to be yielded by the request
   * @param {boolean} translate indicates whether the message should be translated or not
   * @throws {ErrorApp} thrown error, should the assert fail.
   * @return { }
   */
  assert(condition, status, error, translate = false) {
    if (!condition) {
      this.throw(status, error, translate);
    }
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link assert}, but automatically sets the status to 400 BAD REQUEST
   * @param {boolean} condition if set to false; assert will fail and throw.
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Bad request"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @throws {ErrorApp} thrown error, should the assert fail.
   * @return { }
   */
  assertBadRequest(condition, error, translate = false) {
    this.assert(condition, Route.StatusCode.badRequest, error || 'Bad request', translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link assert}, but automatically sets the status to 401 UNAUTHORIZED
   * @param {boolean} condition if set to false; assert will fail and throw.
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Unauthorized"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @throws {ErrorApp} thrown error, should the assert fail.
   * @return { }
   */
  assertUnauthorized(condition, error, translate = false) {
    this.assert(condition, Route.StatusCode.unauthorized, error || 'Unauthorized', translate);
  }

  /**
   * @access public
   * @version 2.0.0
   * @desc same as {@link assert}, but automatically sets the status to 403 FORBIDDEN
   * @param {boolean} condition if set to false; assert will fail and throw.
   * @param {string | object} [error] the error(s) to be yielded by the request, default to "Forbidden"
   * @param {boolean} translate indicates whether the message should be translated or not
   * @throws {ErrorApp} thrown error, should the assert fail.
   * @return { }
   */
  assertForbidden(condition, error, translate = false) {
    this.assert(condition, Route.StatusCode.forbidden, error || 'Forbidden', translate);
  }
}