zacanger/koa-modern-router

View on GitHub
lib/router.js

Summary

Maintainability
F
4 days
Test Coverage
/* eslint-disable max-lines, func-style */

/**
 * RESTful resource routing middleware for koa.
 *
 * @author Alex Mingoia <talk@alexmingoia.com>
 * @link https://github.com/zacanger/koa-modern-router
 */

const compose = require('koa-compose')
const HttpError = require('http-errors')
const methods = require('methods')
const Layer = require('./layer')

/**
 * @module koa-modern-router
 */

module.exports = Router

/**
 * Create a new router.
 *
 * @example
 *
 * Basic usage:
 *
 * ```javascript
 * const Koa = require('koa')
 * const Router = require('koa-modern-router')
 *
 * const app = new Koa()
 * const router = new Router()
 *
 * router.get('/', (ctx, next) => {
 *   // ctx.router available
 * })
 *
 * app
 *   .use(router.routes()
 *   .use(router.allowedMethods())
 * ```
 *
 * @alias module:koa-modern-router
 * @param {Object=} opts
 * @param {String=} opts.prefix prefix router paths
 * @constructor
 */

function Router (opts) {
  if (!(this instanceof Router)) {
    return new Router(opts)
  }

  this.opts = opts || {}
  this.methods = this.opts.methods || [
    'HEAD',
    'OPTIONS',
    'GET',
    'PUT',
    'PATCH',
    'POST',
    'DELETE'
  ]

  this.params = {}
  this.stack = []
}

/**
 * Create `router.verb()` methods, where *verb* is one of the HTTP verbs such
 * as `router.get()` or `router.post()`.
 *
 * Match URL patterns to callback functions or controller actions using `router.verb()`,
 * where **verb** is one of the HTTP verbs such as `router.get()` or `router.post()`.
 *
 * Additionaly, `router.all()` can be used to match against all methods.
 *
 * ```javascript
 * router
 *   .get('/', (ctx, next) => {
 *     ctx.body = 'Hello World!'
 *   })
 *   .post('/users', (ctx, next) => {
 *     // ...
 *   })
 *   .put('/users/:id', (ctx, next) => {
 *     // ...
 *   })
 *   .del('/users/:id', (ctx, next) => {
 *     // ...
 *   })
 *   .all('/users/:id', (ctx, next) => {
 *     // ...
 *   })
 * ```
 *
 * When a route is matched, its path is available at `ctx._matchedRoute` and if named,
 * the name is available at `ctx._matchedRouteName`
 *
 * Route paths will be translated to regular expressions using
 * [path-to-regexp](https://github.com/pillarjs/path-to-regexp).
 *
 * Query strings will not be considered when matching requests.
 *
 * #### Named routes
 *
 * Routes can optionally have names. This allows generation of URLs and easy
 * renaming of URLs during development.
 *
 * ```javascript
 * router.get('user', '/users/:id', (ctx, next) => {
 *  // ...
 * })
 *
 * router.url('user', 3)
 * // => "/users/3"
 * ```
 *
 * #### Multiple middleware
 *
 * Multiple middleware may be given:
 *
 * ```javascript
 * router.get(
 *   '/users/:id',
 *   (ctx, next) => {
 *     return User.findOne(ctx.params.id).then((user) => {
 *       ctx.user = user
 *       next()
 *     })
 *   },
 *   ctx => {
 *     console.log(ctx.user)
 *     // => { id: 17, name: "Zac" }
 *   }
 * )
 * ```
 *
 * ### Nested routers
 *
 * Nesting routers is supported:
 *
 * ```javascript
 * const forums = new Router()
 * const posts = new Router()
 *
 * posts.get('/', (ctx, next) => {...})
 * posts.get('/:pid', (ctx, next) => {...})
 * forums.use('/forums/:fid/posts', posts.routes(), posts.allowedMethods())
 *
 * // responds to "/forums/123/posts" and "/forums/123/posts/123"
 * app.use(forums.routes())
 * ```
 *
 * #### Router prefixes
 *
 * Route paths can be prefixed at the router level:
 *
 * ```javascript
 * const router = new Router({
 *   prefix: '/users'
 * })
 *
 * router.get('/', ...) // responds to "/users"
 * router.get('/:id', ...) // responds to "/users/:id"
 * ```
 *
 * #### URL parameters
 *
 * Named route parameters are captured and added to `ctx.params`.
 *
 * ```javascript
 * router.get('/:category/:title', (ctx, next) => {
 *   console.log(ctx.params)
 *   // => { category: 'programming', title: 'how-to-node' }
 * })
 * ```
 *
 * The [path-to-regexp](https://github.com/pillarjs/path-to-regexp) module is
 * used to convert paths to regular expressions.
 *
 * @name get|put|post|patch|delete|del
 * @memberof module:koa-modern-router.prototype
 * @param {String} path
 * @param {Function=} middleware route middleware(s)
 * @param {Function} callback route callback
 * @returns {Router}
 */

methods.forEach((method) => {
  Router.prototype[method] = function (name, path) {
    let middleware

    if (typeof path === 'string' || path instanceof RegExp || Array.isArray(path)) {
      middleware = Array.prototype.slice.call(arguments, 2)
    } else {
      middleware = Array.prototype.slice.call(arguments, 1)
      path = name
      name = null
    }

    this.register(path, [ method ], middleware, {
      name: name
    })

    return this
  }
})

/**
 * Sort function for array of Layers. Will sort the layers with least specific first
 * and most specific last
 * @param {Layer} a
 * @param {Layer} b
 */
const sortByMostSpecificLayer = (a, b) => {
  const wildA = a.path.endsWith('(.*)')
  const wildB = b.path.endsWith('(.*)')
  if (wildA && wildB) return a.path.length - b.path.length
  const pathA = wildA ? a.path.slice(0, -4) : a.path
  const pathB = wildB ? b.path.slice(0, -4) : b.path
  if (pathA !== pathB) {
    if (pathA.startsWith(pathB)) return 1
    if (pathB.startsWith(pathA)) return -1
  }
  if (wildA) return -1
  if (wildB) return 1
  return 0
}

// Alias for `router.delete()` because delete is a reserved word
Router.prototype.del = Router.prototype['delete']

/**
 * Use given middleware.
 *
 * Middleware run in the order they are defined by `.use()`. They are invoked
 * sequentially, requests start at the first middleware and work their way
 * "down" the middleware stack.
 *
 * @example
 *
 * ```javascript
 * // session middleware will run before authorize
 * router
 *   .use(session())
 *   .use(authorize());
 *
 * // use middleware only with given path
 * router.use('/users', userAuth())
 *
 * // or with an array of paths
 * router.use(['/users', '/admin'], userAuth())
 *
 * app.use(router.routes())
 * ```
 *
 * @param {String=} path
 * @param {Function} middleware
 * @param {Function=} ...
 * @returns {Router}
 */

Router.prototype.use = function () {
  const router = this
  const middleware = Array.prototype.slice.call(arguments)
  let path

  // support array of paths
  if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') {
    middleware[0].forEach((p) => {
      router.use.apply(router, [ p ].concat(middleware.slice(1)))
    })

    return this
  }

  const hasPath = typeof middleware[0] === 'string'
  if (hasPath) {
    path = middleware.shift()
  }

  middleware.forEach(function (m) {
    if (m.router) {
      m.router.stack.forEach(function (nestedLayer) {
        // Need to create new Layer instance to avoid mutating existing one
        const temp = new Layer(nestedLayer.path, nestedLayer.methods, nestedLayer.stack, nestedLayer.opts)
        if (path) temp.setPrefix(path)
        if (router.opts.prefix) temp.setPrefix(router.opts.prefix)

        // We need to check if any params exist because they are applicable to every layer in router stack
        if (router.params) {
          const paramsList = Object.keys(router.params)
          paramsList.forEach((param) => {
            // Apply param to new Layer
            temp.param(param, router.params[param])
          })
        }
        router.stack.push(temp)
      })

      if (router.params) {
        Object.keys(router.params).forEach(function (key) {
          m.router.param(key, router.params[key])
        })
      }
    } else {
      router.register(path || '(.*)', [], m, { end: false, ignoreCaptures: !hasPath })
    }
  })

  return this
}

/**
 * Set the path prefix for a Router instance that was already initialized.
 *
 * @example
 *
 * ```javascript
 * router.prefix('/things/:thing_id')
 * ```
 *
 * @param {String} prefix
 * @returns {Router}
 */

Router.prototype.prefix = function (prefix) {
  prefix = prefix.replace(/\/$/, '')

  this.opts.prefix = prefix

  this.stack.forEach((route) => {
    route.setPrefix(prefix)
  })

  return this
}

/**
 * Returns router middleware which dispatches a route matching the request.
 *
 * @returns {Function}
 */

Router.prototype.routes = Router.prototype.middleware = function () {
  const router = this

  const dispatch = function dispatch (ctx, next) {
    const path = router.opts.routerPath || ctx.routerPath || ctx.path
    const matched = router.match(path, ctx.method)

    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path)
    } else {
      ctx.matched = matched.path
    }

    ctx.router = router

    if (!matched.route) return next()

    const matchedLayers = matched.pathAndMethod
    matchedLayers.sort(sortByMostSpecificLayer)
    const mostSpecificLayer = matched.longestMatch
    ctx._matchedRoute = mostSpecificLayer.path
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name
    }
    const layerChain = matchedLayers.reduce((memo, layer) => {
      memo.push((ctx, next) => {
        ctx.captures = layer.captures(path, ctx.captures)
        ctx.params = layer.params(path, ctx.captures, ctx.params)
        ctx.routerName = layer.name
        return next()
      })
      return memo.concat(layer.stack)
    }, [])
    return compose(layerChain)(ctx, next)
  }

  dispatch.router = this

  return dispatch
}

/**
 * Returns separate middleware for responding to `OPTIONS` requests with
 * an `Allow` header containing the allowed methods, as well as responding
 * with `405 Method Not Allowed` and `501 Not Implemented` as appropriate.
 *
 * @example
 *
 * ```javascript
 * const Koa = require('koa')
 * const Router = require('koa-modern-router')
 *
 * const app = new Koa()
 * const router = new Router()
 *
 * app.use(router.routes())
 * app.use(router.allowedMethods())
 * ```
 *
 * **Example with [Boom](https://github.com/hapijs/boom)**
 *
 * ```javascript
 * const Koa = require('koa')
 * const Router = require('koa-modern-router')
 * const Boom = require('boom')
 *
 * const app = new Koa()
 * const router = new Router()
 *
 * app.use(router.routes())
 * app.use(router.allowedMethods({
 *   throw: true,
 *   notImplemented: () => new Boom.notImplemented(),
 *   methodNotAllowed: () => new Boom.methodNotAllowed()
 * }))
 * ```
 *
 * @param {Object=} options
 * @param {Boolean=} options.throw throw error instead of setting status and header
 * @param {Function=} options.notImplemented throw the returned value in place of the default NotImplemented error
 * @param {Function=} options.methodNotAllowed throw the returned value in place of the default MethodNotAllowed error
 * @returns {Function}
 */

Router.prototype.allowedMethods = function (options) {
  options = options || {}
  const implemented = this.methods

  return function allowedMethods (ctx, next) {
    return next().then(function () {
      const allowed = {}

      if (!ctx.status || ctx.status === 404) {
        ctx.matched.forEach((route) => {
          route.methods.forEach((method) => {
            allowed[method] = method
          })
        })

        const allowedArr = Object.keys(allowed)

        if (!~implemented.indexOf(ctx.method)) {
          if (options.throw) {
            let notImplementedThrowable
            if (typeof options.notImplemented === 'function') {
              notImplementedThrowable = options.notImplemented() // set whatever the user returns from their function
            } else {
              notImplementedThrowable = new HttpError.NotImplemented()
            }
            throw notImplementedThrowable
          } else {
            ctx.status = 501
            ctx.set('Allow', allowedArr.join(', '))
          }
        } else if (allowedArr.length) {
          if (ctx.method === 'OPTIONS') {
            ctx.status = 200
            ctx.body = ''
            ctx.set('Allow', allowedArr.join(', '))
          } else if (!allowed[ctx.method]) {
            if (options.throw) {
              let notAllowedThrowable
              if (typeof options.methodNotAllowed === 'function') {
                notAllowedThrowable = options.methodNotAllowed() // set whatever the user returns from their function
              } else {
                notAllowedThrowable = new HttpError.MethodNotAllowed()
              }
              throw notAllowedThrowable
            } else {
              ctx.status = 405
              ctx.set('Allow', allowedArr.join(', '))
            }
          }
        }
      }
    })
  }
}

/**
 * Register route with all methods.
 *
 * @param {String} name Optional.
 * @param {String} path
 * @param {Function=} middleware You may also pass multiple middleware.
 * @param {Function} callback
 * @returns {Router}
 * @private
 */

Router.prototype.all = function (name, path) {
  let middleware

  if (typeof path === 'string' || path instanceof RegExp || Array.isArray(path)) {
    middleware = Array.prototype.slice.call(arguments, 2)
  } else {
    middleware = Array.prototype.slice.call(arguments, 1)
    path = name
    name = null
  }

  this.register(path, methods, middleware, {
    name: name
  })

  return this
}

/**
 * Redirect `source` to `destination` URL with optional 30x status `code`.
 *
 * Both `source` and `destination` can be route names.
 *
 * ```javascript
 * router.redirect('/login', 'sign-in')
 * ```
 *
 * This is equivalent to:
 *
 * ```javascript
 * router.all('/login', (ctx) => {
 *   ctx.redirect('/sign-in')
 *   ctx.status = 301
 * })
 * ```
 *
 * @param {String} source URL or route name.
 * @param {String} destination URL or route name.
 * @param {Number=} code HTTP status code (default: 301).
 * @returns {Router}
 */

Router.prototype.redirect = function (source, destination, code) {
  // lookup source route by name
  if (source[0] !== '/') {
    source = this.url(source)
  }

  // lookup destination route by name
  if (destination[0] !== '/') {
    destination = this.url(destination)
  }

  return this.all(source, (ctx) => {
    ctx.redirect(destination)
    ctx.status = code || 301
  })
}

/**
 * Create and register a route.
 *
 * @param {String} path Path string.
 * @param {Array.<String>} methods Array of HTTP verbs.
 * @param {Function} middleware Multiple middleware also accepted.
 * @returns {Layer}
 * @private
 */

Router.prototype.register = function (path, methods, middleware, opts) {
  opts = opts || {}

  const router = this
  const stack = this.stack

  // support array of paths
  if (Array.isArray(path)) {
    path.forEach(function (p) {
      router.register(p, methods, middleware, opts)
    })

    return this
  }

  // create route
  const route = new Layer(path, methods, middleware, {
    end: opts.end === false ? opts.end : true,
    name: opts.name,
    sensitive: opts.sensitive || this.opts.sensitive || false,
    strict: opts.strict || this.opts.strict || false,
    prefix: opts.prefix || this.opts.prefix || '',
    ignoreCaptures: opts.ignoreCaptures
  })

  if (this.opts.prefix) {
    route.setPrefix(this.opts.prefix)
  }

  // add parameter middleware
  Object.keys(this.params).forEach(function (param) {
    route.param(param, this.params[param])
  }, this)

  stack.push(route)

  return route
}

/**
 * Lookup route with given `name`.
 *
 * @param {String} name
 * @returns {Layer|false}
 */

Router.prototype.route = function (name) {
  const routes = this.stack

  for (let len = routes.length, i = 0; i < len; i++) {
    if (routes[i].name && routes[i].name === name) {
      return routes[i]
    }
  }

  return false
}

/**
 * Generate URL for route. Takes a route name and map of named `params`.
 *
 * @example
 *
 * ```javascript
 * router.get('user', '/users/:id', (ctx, next) => {
 *   // ...
 * })
 *
 * router.url('user', 3)
 * // => "/users/3"
 *
 * router.url('user', { id: 3 })
 * // => "/users/3"
 *
 * router.use((ctx, next) => {
 *   // redirect to named route
 *   ctx.redirect(ctx.router.url('sign-in'))
 * })
 *
 * router.url('user', { id: 3 }, { query: { limit: 1 } })
 * // => "/users/3?limit=1"
 *
 * router.url('user', { id: 3 }, { query: "limit=1" })
 * // => "/users/3?limit=1"
 * ```
 *
 * @param {String} name route name
 * @param {Object} params url parameters
 * @param {Object} [options] options parameter
 * @param {Object|String} [options.query] query options
 * @returns {String|Error}
 */

Router.prototype.url = function (name) {
  const route = this.route(name)

  if (route) {
    const args = Array.prototype.slice.call(arguments, 1)
    return route.url.apply(route, args)
  }

  throw new Error('No route found for name: ' + name)
}

/**
 * Match given `path` and return corresponding routes.
 *
 * @param {String} path
 * @param {String} method
 * @returns {Object.<path, pathAndMethod>} returns layers that matched path and
 * path and method.
 * @private
 */

Router.prototype.match = function (path, method) {
  const layers = this.stack
  let layer
  let maxMatchLength = -1
  let maxMatchPathLength = -1
  const matched = {
    path: [],
    pathAndMethod: [],
    route: false,
    longestMatch: null
  }

  for (let len = layers.length, i = 0; i < len; i++) {
    layer = layers[i]
    const matchLength = layer.matchLength(path)

    if (~matchLength) {
      matched.path.push(layer)

      if (layer.methods.length === 0 || ~layer.methods.indexOf(method)) {
        const matchPathLength = layer.plainPath.length
        if (matchLength > maxMatchLength) {
          maxMatchLength = matchLength
          maxMatchPathLength = matchPathLength
          matched.longestMatch = layer
        } else if (
          matchLength === maxMatchLength &&
          matchPathLength >= maxMatchPathLength
        ) {
          // tiebreak based on pattern length
          maxMatchPathLength = matchPathLength
          matched.longestMatch = layer
        }

        matched.pathAndMethod.push(layer)
        if (layer.methods.length) matched.route = true
      }
    }
  }

  return matched
}

/**
 * Run middleware for named route parameters. Useful for auto-loading or
 * validation.
 *
 * @example
 *
 * ```javascript
 * router
 *   .param('user', (id, ctx, next) => {
 *     ctx.user = users[id];
 *     if (!ctx.user) return ctx.status = 404
 *     return next()
 *   })
 *   .get('/users/:user', (ctx) => {
 *     ctx.body = ctx.user
 *   })
 *   .get('/users/:user/friends', (ctx) => {
 *     return ctx.user.getFriends().then((friends) => {
 *       ctx.body = friends
 *     })
 *   })
 *   // /users/3 => {"id": 3, "name": "Zac"}
 *   // /users/3/friends => [{"id": 4, "name": "TJ"}]
 * ```
 *
 * @param {String} param
 * @param {Function} middleware
 * @returns {Router}
 */

Router.prototype.param = function (param, middleware) {
  this.params[param] = middleware
  this.stack.forEach((route) => {
    route.param(param, middleware)
  })
  return this
}

/**
 * Generate URL from url pattern and given `params`.
 *
 * @example
 *
 * ```javascript
 * const url = Router.url('/users/:id', { id: 1 })
 * // => "/users/1"
 * ```
 *
 * @param {String} path url pattern
 * @param {Object} params url parameters
 * @returns {String}
 */

Router.url = function (path) {
  const args = Array.prototype.slice.call(arguments, 1)
  return Layer.prototype.url.apply({ plainPath: path }, args)
}