adapt-authoring-server/lib/Router.js

import _ from 'lodash'
import { App } from 'adapt-authoring-core'
import express from 'express'
import ServerUtils from './ServerUtils.js'
/**
 * Handles the Express routing functionality
 * @memberof server
 */
class Router {
  /**
   * If passing an {@link ExpressRouter} as the parentRouter, it is assumed that the Express Router is the top of the router 'heirarchy' (which will have an impact of some of the {@link Router} methods)
   * @param {String} root Route API endpoint for this router
   * @param {Router|ExpressRouter} parentRouter Parent to mount router
   * @param {Array<Route>} routes Array of routes
   */
  constructor (root, parentRouter, routes) {
    /**
     * The root route the router will be mounted at
     * @type {String}
     */
    this.root = root
    /**
     * Routes config
     * @type {Array<Route>}
     */
    this.routes = routes ?? []
    /**
     * Express router instance
     * @type {external:ExpressRouter}
     */
    this.expressRouter = express.Router()
    /**
     * Express router instance
     * @type {ExpressApp|Router}
     */
    this.parentRouter = parentRouter
    /**
     * List of sub-routers
     * @type {Array<Router>}
     */
    this.childRouters = []
    /**
     * Middleware stack to be added directly to the router
     * @type {Array<Function>}
     */
    this.routerMiddleware = [
      ServerUtils.addErrorHandler
    ]
    /**
     * Middleware stack to be added before route handlers (useful if you need access to specific request attributes that don't exist when standard middleware runs)
     * @type {Array<Function>}
     */
    this.handlerMiddleware = [
      ServerUtils.addExistenceProps,
      ServerUtils.handleInternalRoutes
    ]
    /** @ignore */this._initialised = false
  }

  /**
   * Returns the map of routes attached to this router
   * @type {Object}
   */
  get map () {
    return ServerUtils.generateRouterMap(this)
  }

  /**
   * Generates this router's path from its ancestors
   * @type {String}
   */
  get path () {
    let p = ''

    if (_.isString(this.parentRouter.path)) {
      p += this.parentRouter.path
    }
    if (p[p.length - 1] !== '/' && this.root[0] !== '/') {
      p += '/'
    }
    return p + this.root
  }

  /**
   * Returns the URL for the router
   * @return {String} The URL
   */
  get url () {
    try {
      const serverUrl = App.instance.dependencyloader.instances['adapt-authoring-server'].url
      return serverUrl + this.path
    } catch (e) {
      this.log('error', e)
      return ''
    }
  }

  /**
   * Adds middleware to the router stack. Accepts multiple params.
   * @param {...Function} func Middleware function(s) to be added
   * @return {AbstractApiModule} This instance, for chaining
   * @see https://expressjs.com/en/guide/using-middleware.html
   */
  addMiddleware (...func) {
    return this._addMiddleware(this.routerMiddleware, ...func)
  }

  /**
   * Adds middleware to be called prior to any route handlers. Accepts multiple params. Useful if you need access to specific request attributes that don't exist when standard middleware runs.
   * @param {...Function} func Middleware function(s) to be added
   * @return {AbstractApiModule} This instance, for chaining
   * @see https://expressjs.com/en/guide/using-middleware.html
   */
  addHandlerMiddleware (...func) {
    return this._addMiddleware(this.handlerMiddleware, ...func)
  }

  /** @ignore */ _addMiddleware (stack, ...func) {
    if (func.length) {
      this.warnOnInited('middleware may not be called before any route handlers')
      func.forEach(f => _.isFunction(f) && !stack.includes(f) && stack.push(f))
    }
    return this
  }

  /**
   * Recursively gets middleware of the current router heirarchy
   * @param {Router} router The current router (used when run recursively)
   * @param {Array<function>} middleware Middleware function(s) (used when run recursively)
   * @return {Array<Function>}
   */
  getHandlerMiddleware (router = this, middleware = []) {
    if (!(router instanceof Router)) {
      return middleware
    }
    return _.uniq(this.getHandlerMiddleware(router.parentRouter, [...router.handlerMiddleware, ...middleware]))
  }

  /**
   * Store route definition. Accepts multiple params.
   * @param {...Route} route Config of route(s) to be added
   * @return {AbstractApiModule} This instance, for chaining
   */
  addRoute (...route) {
    const inited = this.warnOnInited(`cannot set further routes (${this.path} ${route.map(r => r.route).join(', ')})`)
    if (!inited && route.length) {
      this.routes.push(...route.filter(this.validateRoute, this))
    }
    return this
  }

  /**
   * Function for filtering bad route configs
   * @param {Route} r Route config
   * @return {Boolean}
   */
  validateRoute (r) {
    const ePrefix = `invalid route config for ${this.route} router`
    if (!_.isString(r.route)) {
      this.log('warn', `${ePrefix}, route must be a string`)
      return false
    }
    if (!r.handlers) {
      this.log('warn', `${ePrefix}, no route handlers defined`)
      return false
    }
    // handlers can be single function or array of functions
    const allHandlersFuncs = Object.entries(r.handlers).every(([m, h]) => {
      if (this.expressRouter[m] === undefined) {
        this.log('warn', `${ePrefix}, ${m} must be a valid Express.js function`)
        return false
      }
      if (!_.isFunction(h) && !(_.isArray(h) && h.every(_.isFunction))) {
        this.log('warn', `${ePrefix} ${m.toUpperCase()} ${r.route}, all route handlers must be functions`)
        return false
      }
      return true
    })
    if (!allHandlersFuncs) {
      return false
    }
    return true
  }

  /**
   * Creates and adds a sub-router to this router.
   * @param {string} root The root of the child router
   * @param {Array<Route>} routes Array of Routes to add
   * @return {Router} The new router instance
   */
  createChildRouter (root, routes) {
    if (this.warnOnInited(`cannot create further child routers (${this.path}/${root})`)) {
      return this
    }
    const router = new Router(root, this, routes)

    this.childRouters.push(router)

    this.log('debug', 'ADD_ROUTER', router.path)

    return router
  }

  /**
   * Initialises the API
   */
  init () {
    if (this.warnOnInited(`(${this.path})`)) {
      return
    }
    if (this.routerMiddleware.length) {
      this.expressRouter.use(...this.routerMiddleware)
    }
    if (this.childRouters.length) {
      this.childRouters.forEach(c => {
        c.init()
        this.expressRouter.use(c.root, c.expressRouter)
      })
    }
    if (this.routes.length) {
      this.routes.forEach(r => {
        Object.entries(r.handlers).forEach(([method, handler]) => {
          this.expressRouter[method](r.route, ServerUtils.cacheRouteConfig(r), ...this.getHandlerMiddleware(), handler)
          this.log('debug', 'ADD_ROUTE', method.toUpperCase(), `${this.path !== '/' ? this.path : ''}${r.route}`)
        })
      })
    }
    // add to the parent stack
    if (this.parentRouter instanceof Router) {
      this.parentRouter.expressRouter.use(`/${this.root}`, this.expressRouter)
    } else {
      const route = this.root[0] !== '/' ? `/${this.root}` : this.root
      this.parentRouter.use(route, this.expressRouter)
    }
    this._initialised = true
  }

  /**
   * Shortcut for checking Router has initialised, logging a warning if not
   * @param {String} message Message to log on error
   * @return {Boolean}
   */
  warnOnInited (message) {
    if (this._initialised) {
      this.log('warn', `router has already initialised, ${message}`)
    }
    return this._initialised
  }

  /**
   * Creates an array defining the router inheritance hierarchy
   * @param {Router} router The root router
   * @return {Array}
   */
  flattenRouters (router = this) {
    return router.childRouters.reduce((a, c) => {
      c.childRouters ? a.push(...this.flattenRouters(c)) : a.push(c)
      return a
    }, [router])
  }

  /**
   * Logs a message
   * @param {String} level Level of log
   * @param {...*} args Arguments to be logged
   */
  log (level, ...args) {
    App.instance.logger.log(level, this.constructor.name.toLowerCase(), ...args)
  }
}

export default Router