wdullaer/version-validator

View on GitHub
lib/index.js

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
'use strict'
/**
 * A function generating a custom error to be passed into next when the version
 * is not valid
 * @typedef {function} ErrorGenerator
 * @return {error}
 * @public
 */

/**
 * An object containing the configuration options of the validateVersion function
 * @typedef {object} ValidatorOptions
 * @property {string[]} versions
 * @property {?boolean} sendReply
 * @property {?boolean} isMandatory
 * @property {?boolean} sendVersionHeader
 * @property {?ErrorGenerator} generateError
 * @public
 */

const semver = require('semver')

/**
 * Generates a middleware to parse and validate versions out of the request
 * @example
 * let app = require('express')();
 * let vv = require('version-validator');
 *
 * app.use(vv.validateVersion({
 *   versions: ['1.0.0'],
 *   isMandatory: true
 * }));
 * app.all('/', (req, res) => res.status(200).json({
 *   req.version,
 *   req.matchedVersion
 * }));
 *
 * @param  {ValidatorOptions|string[]} args An object of options, or an array of supported semver versions
 * @return {Middleware}                     A middleware that validates the requested version and populates `req.version` and `req.matchedVersion`
 * @throws {TypeError}                      Properties of args must have their specified types
 * @public
 */
function validateVersion (args) {
  args = validateArgs(args)
  const options = {
    supportedVersions: args.versions,
    handleUnsupported: args.sendReply ? sendReply.bind(args.versions) : sendError.bind(args.generateError),
    initialVersion: args.isMandatory ? null : '*',
    sendVersionHeader: args.sendVersionHeader
  }
  return addVersion.bind(options)
}

/**
 * Partial middleware that will be extended by validateVersion
 * @param {Request}  req  An expressjs request object
 * @param {Response} res  An expressjs request object
 * @param {function} next A callback that signals the middleware is done processing the request
 * @return {undefined}    Returns undefined
 * @private
 */
function addVersion (req, res, next) {
  let requestedVersion = this.initialVersion
  if (req.query.version) requestedVersion = req.query.version
  else if (req.headers['accept-version']) requestedVersion = req.headers['accept-version']

  const matchedVersion = semver.maxSatisfying(this.supportedVersions, requestedVersion)

  if (!matchedVersion) return this.handleUnsupported(req, res, next)
  req.matchedVersion = matchedVersion
  req.version = requestedVersion
  if (this.sendVersionHeader) res.set('API-VERSION', matchedVersion)
  next()
}

/**
 * Validates the options passed to validateVersion and sets default values where required
 * @param  {ValidatorOptions} args The options passed to validateVersion
 * @return {ValidatorOptions}      The options with default values where necessary
 * @throws {TypeError}             Properties of args must have their specified types
 * @private
 */
function validateArgs (args) {
  if (Array.isArray(args)) args = { versions: args }

  if (!args.versions) throw new TypeError('Arguments should contain a list of supported versions')
  if (!Array.isArray(args.versions)) throw new TypeError('versions should be a list of strings')
  args.versions.forEach((version) => {
    if (!semver.valid(version)) throw new TypeError(`Version ${version} is not a valid semver string`)
  })

  if (args.sendReply === undefined) args.sendReply = true
  if (typeof args.sendReply !== 'boolean') throw new TypeError('sendReply should be a boolean')

  if (args.isMandatory === undefined) args.isMandatory = false
  if (typeof args.isMandatory !== 'boolean') throw new TypeError('isMandatory should be a boolean')

  if (args.sendVersionHeader === undefined) args.sendVersionHeader = true
  if (typeof args.sendVersionHeader !== 'boolean') throw new TypeError('sendVersionHeader should be a boolean')

  if (args.generateError === undefined) args.generateError = generateDefaultError.bind(args.versions)
  if (args.generateError && typeof args.generateError !== 'function') throw new TypeError('generateError should be a function')

  return args
}

/**
 * Call the next middleware with the error generated by this()
 * `this` is a function that will generate the error to be passed into the callback (bound at configuration time)
 * @this   ValidatorOptions.generateError
 * @param  {Request}  req  An expressjs Request object
 * @param  {Response} res  An expressj Response object
 * @param  {function} next The callback to signal that we are done processing the Request
 * @return {undefined}     Returns void
 * @private
 */
function sendError (req, res, next) {
  next(this())
}

/**
 * Send a reply to the client to indicate that requested version is not Supported
 * @param  {Request}  req An expressjs Request object
 * @param  {Response} res An expressjs Response object
 * @return {undefined}    Returns void
 * @private
 */
function sendReply (req, res) {
  const output = {
    statusCode: 400,
    title: 'Invalid Version',
    detail: `Supported Versions: [${this.join(', ')}]`
  }
  res.status(400).json(output)
}

/**
 * A default implementation of generateError, to serve as a bound argument in sendError
 * @return {error} An error object with error.status and error.detail set
 * @private
 */
function generateDefaultError () {
  const error = new Error('Invalid Version')
  error.status = 400
  error.detail = `Supported Versions: [${this.join(', ')}]`
  return error
}

/**
 * Generates a middleware route that routes the request into `next()` if the version
 * matches and `next('route')` if the version does not matches
 * @example
 * let app = require('express')();
 * let vv = require('version-validator');
 *
 * app.use(vv.validateVersion(['1.0.0']));
 * app.all('/', vv.isVersion('1.0.0'), (req, res) => res.status(200).send(`${req.matchedVersion} is 1.0.0`));
 * app.all('/', (req, res) => res.status(200).send(`${req.matchedVersion} is not 1.0.0`));
 *
 * @param  {string}     version A semver string
 * @return {Middleware}         A middleware to route requests for this version
 * @throws {TypeError}         `version` must be a valid semver string
 * @public
 */
function isVersion (version) {
  if (!semver.valid(version)) throw new TypeError(`Version ${version} is not a valid semver string`)
  return routeVersion.bind(version)
}

/**
 * Partial middleware function. Will route requests for the version bound into @this
 * @this   version
 * @param  {Request}  req  An expressjs Request object
 * @param  {Response} res  An expressjs Response object
 * @param  {function} next A callback to signal that the middleware is done processing the requests
 * @return {undefined}     Returns void
 * @private
 */
function routeVersion (req, res, next) {
  if (semver.eq(req.matchedVersion, this.toString())) return next()
  return next('route')
}

module.exports = {
  isVersion,
  validateVersion
}