luxe-eng/koa-spec

View on GitHub
lib/index.js

Summary

Maintainability
F
5 days
Test Coverage
'use strict';

const debug = require('debug')('koa-spec');
const _ = require('lodash');
const path = require('path');
const HTTPStatus = require('http-status');
const utils = require('./utils');
const parseBoolean = utils.parseBoolean;
const errors = require('./errors');

const RouterError = errors.RouterError;

const RouteError = errors.RouteError;
const RouteNotImplementedError = errors.RouteNotImplementedError;

const ParameterValidationError = errors.ParameterValidationError;

const SourceValidationError = errors.SourceValidationError;
const RequiredValidationError = errors.RequiredValidationError;
const NullableValidationError = errors.NullableValidationError;
const TypeValidationError = errors.TypeValidationError;
const FormatValidationError = errors.FormatValidationError;

const CONTROLLER_DIRECTORY_DEFAULT = './controllers';
const STRICT_MODE_DEFAULT = true;

const INTEGER_INT32_MAX_VALUE = 2147483647;
const INTEGER_INT32_MIN_VALUE = -2147483648;
const INTEGER_INT64_MAX_VALUE = Number.MAX_SAFE_INTEGER;
const INTEGER_INT64_MIN_VALUE = Number.MIN_SAFE_INTEGER;
const NUMBER_FLOAT_MIN_VALUE = -3.4028235E38;
const NUMBER_FLOAT_MAX_VALUE = 3.4028235E38;

module.exports = function (uri, options) {
  const refOptions = (options && options.refOptions) ? options.refOptions : undefined;
  const spec = utils.readSpec(uri, refOptions);

  const routerOptions = (options && options.routerOptions) ? options.routerOptions : undefined;
  const router = createRouter(spec, routerOptions);

  return {
    spec   : spec.resolved,
    refs   : spec.refs,
    router : router
  }
};

function createRouter(spec, options) {
  function getControllerMethod(controllerDirectory, controllerName, controllerMethodName, strictMode, method, route) {
    if (!controllerName) {
      throw new RouteError(method, route, `Controller name not specified. Specify via 'x-controller'.`);
    }

    const controllerPath = path.join(controllerDirectory, controllerName);
    if (!utils.isRequireResolvable(controllerPath)) {
      if (strictMode) {
        throw new RouteError(method, route, `Controller '${controllerName}' does not exist.`);
      }
    } else {
      if (!controllerMethodName) {
        throw new RouteError(method, route, `Controller '${controllerName}' method name not specified. Specify via 'x-controller-method'.`);
      }
      const controller = require(controllerPath);
      const controllerMethod = controller[controllerMethodName];
      if (controllerMethod) {
        return controllerMethod;
      }
      if (strictMode) {
        throw new RouteError(method, route, `Controller '${controllerName}' does not have a method '${controllerMethodName}'.`);
      }
    }

    /* Fallback */
    return createNotImplementedControllerMethod(method, route);
  }

  function createNotImplementedControllerMethod(method, route) {
    return function* (next) {
      this.status = HTTPStatus.NOT_IMPLEMENTED;
      const err = new RouteNotImplementedError(method, route);
      this.body = {
        code    : err.code,
        message : err.message,
        route   : {
          method : method,
          path   : route
        }
      };
    }
  }

  function checkMethodInfo(method, route, methodInfo) {
    // Check if any of the parameters of this method uses a body parameter:
    const bodyParameter = _.find(methodInfo.parameters, {in : 'body'});
    if (bodyParameter) {
      // Make sure the koa-bodyparser is available:
      if (!require('koa-bodyparser')) {
        throw new RouteError(method, route, `Detected 'body' parameter: '${bodyParameter.name}' but module 'koa-bodyparser' isn't available. Install via 'npm install --save koa-bodyparser'.`)
      }
    }
    // Check if any of the parameters of this method uses a formData parameter:
    const formDataParameter = _.find(methodInfo.parameters, {in : 'formData'});
    if (formDataParameter) {
      // Make sure the koa-bodyparser is available:
      if (!require('koa-bodyparser')) {
        throw new RouteError(method, route, `Detected 'formData' parameter: '${formDataParameter.name}' but module 'koa-bodyparser' isn't available. Install via 'npm install --save koa-bodyparser'.`)
      }
    }
  }

  return function () {
    const koarouter = require('koa-router');
    if (!koarouter) {
      throw new RouterError(`Module 'koa-router' isn't available. Install via 'npm install --save koa-router'.`)
    }
    const router = koarouter();

    const controllerDirectory = (options && !_.isUndefined(options.controllerDirectory)) ? options.controllerDirectory : CONTROLLER_DIRECTORY_DEFAULT;
    const strictMode = (options && !_.isUndefined(options.strictMode)) ? options.strictMode : STRICT_MODE_DEFAULT;

    if (!utils.isDirectory(controllerDirectory)) {
      throw new RouterError(`Controller directory '${controllerDirectory}' does not exist.`);
    }

    const routes = spec.resolved.paths;
    _.forOwn(routes, function (methods, route) {

      /* Convert from spec to router parameter syntax: */
      route = route.replace(/{[^/]*}/g, function replacer(match) {
        /* Cut first and last character, prepend with colon: */
        return ':' + match.slice(1, -1);
      });

      _.forOwn(methods, function (methodInfo, method) {
        const controllerName = methodInfo['x-controller'];
        const controllerMethodName = methodInfo['x-controller-method'];

        const controllerMethod = getControllerMethod(controllerDirectory, controllerName, controllerMethodName, strictMode, method, route);

        if (router[method]) {
          checkMethodInfo(method, route, methodInfo);

          const requestValidator = createRequestValidator(spec, method, route, methodInfo.parameters);
          router[method](route, requestValidator, controllerMethod);
        } else {
          throw new RouteError(method, route, `Method '${method}' does not exist.`);
        }
      });
    });

    return router;
  }
}

function createRequestValidator(spec, method, route, parameterDefinitions) {
  function validateBoolean(name, type, format, value) {
    const actualValue = parseBoolean(value);

    if (!_.isBoolean(actualValue)) {
      throw new TypeValidationError(name, type, format, value, `Not a 'boolean'.`);
    }

    return actualValue;
  }

  function validateInteger(name, type, format, value) {
    const actualValue = parseInt(value, 10);

    if (isNaN(actualValue)) {
      throw new TypeValidationError(name, type, format, value, `Not an 'integer'.`);
    } else if (parseFloat(value) % 1 !== 0) { // Checks if this actually was a float, not an integer!
      throw new TypeValidationError(name, type, format, value, `Not an 'integer'.`);
    }

    switch (format) {
      case 'int32':
        return validateIntegerInt32(name, type, format, value, actualValue);
      case 'int64':
        return validateIntegerInt64(name, type, format, value, actualValue);
      default:
        throw new FormatValidationError(name, type, format, value, `Unknown format: ${format}.`);
    }
  }

  function validateIntegerInt32(name, type, format, value, actualValue) {
    if (actualValue > INTEGER_INT32_MAX_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Maximum integer (int32) value: ${INTEGER_INT32_MAX_VALUE}.`);
    } else if (actualValue < INTEGER_INT32_MIN_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Minimum integer (int32) value: ${INTEGER_INT32_MIN_VALUE}.`);
    }
    return actualValue;
  }

  function validateIntegerInt64(name, type, format, value, actualValue) {
    if (actualValue > INTEGER_INT64_MAX_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Maximum integer (int64) value: ${INTEGER_INT64_MAX_VALUE}.`);
    } else if (actualValue < INTEGER_INT64_MIN_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Minimum integer (int64) value: ${INTEGER_INT64_MIN_VALUE}.`);
    }
    return actualValue;
  }

  function validateNumber(name, type, format, value) {
    const actualValue = parseFloat(value);
    if (isNaN(actualValue)) {
      throw new TypeValidationError(name, type, format, value, `Not a 'number'.`);
    }

    switch (format) {
      case 'float':
        return validateNumberFloat(name, type, format, value, actualValue);
      case 'double':
        return validateNumberDouble(name, type, format, value, actualValue);
      default:
        throw new FormatValidationError(name, type, format, value, `Unknown format: ${format}.`);
    }
  }

  function validateNumberFloat(name, type, format, value, actualValue) {
    if (actualValue > NUMBER_FLOAT_MAX_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Maximum number (float) value: ${NUMBER_FLOAT_MAX_VALUE}.`);
    } else if (actualValue < NUMBER_FLOAT_MIN_VALUE) {
      throw new FormatValidationError(name, type, format, value, `Minimum number (float) value: ${NUMBER_FLOAT_MIN_VALUE}.`);
    }
    return actualValue;
  }

  function validateNumberDouble(name, type, format, value, actualValue) {
    return actualValue;
  }

  function validateString(name, type, format, value) {
    const actualValue = value;
    if (!_.isString(actualValue)) {
      throw new TypeValidationError(name, type, format, value, `Not a 'string'.`);
    }

    switch (format) {
      case undefined:
        // No specified format means a simple string. Nothing to check for here.
        return actualValue;
      case 'uuid':
        return validateStringUUID(name, type, format, value, actualValue);
      case 'isbn':
        return validateStringISBN(name, type, format, value, actualValue);
      case 'date-time':
        return validateStringDateTime(name, type, format, value, actualValue);
      default:
        throw new FormatValidationError(name, type, format, value, `Unknown format: ${format}.`);
    }
  }

  function validateStringUUID(name, type, format, value, actualValue) {
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
      throw new FormatValidationError(name, type, format, value, `Not a 'uuid'.`);
    }
    return actualValue;
  }

  function validateStringISBN(name, type, format, value, actualValue) {
    if (!/^(?=[0-9X]{10}$|(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$/i.test(value)) {
      throw new FormatValidationError(name, type, format, value, `Not a 'isbn'.`);
    }
    return actualValue;
  }

  function validateStringDateTime(name, type, format, value, actualValue) {
    /* Performance: ≈4M/sec (2.3 GHz Intel Core i7) */
    if (!/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/i.test(value)) {
      throw new FormatValidationError(name, type, format, value, `Not a 'date-time' (ISO 8601).`);
    }
    const date = new Date(actualValue);
    return date;
  }

  function validateArray(name, type, format, items, values) {
    if (!_.isArray(values)) {
      throw new TypeValidationError(name, type, format, values, `Not an 'array'.`);
    }
    if (!items) {
      throw new TypeValidationError(name, type, format, values, `Items not found.`);
    }
    const refName = items['$ref'];
    if (refName) {
      const ref = spec.refs[`${refName}/properties/${name}/items`];
      items = _.get(spec.resolved, utils.pathFromPtr(ref.uri).join('.'));
    }

    return _.map(values, value => {
      return validateValueType(name, items.type, items.format, items['x-nullable'], undefined, items.properties, items.required, value);
    });
  }

  function validateObject(name, type, format, properties, required, value) {
    const actualValue = {};
    if (!properties) {
      throw new TypeValidationError(name, type, format, value, `Properties not found.`);
    }

    _.forOwn(properties, (propertyInfo, propertyName) => {
      const refName = propertyInfo['$ref'];
      if (refName) {
        const ref = spec.refs[`${refName}/properties/${propertyName}`];
        propertyInfo = _.get(spec.resolved, utils.pathFromPtr(ref.uri).join('.'));
      }

      const propertyValue = value[propertyName];
      /* Check for required properties: */
      if (_.includes(required, propertyName)) {
        if (_.isUndefined(propertyValue)) {
          throw new RequiredValidationError(propertyName, propertyInfo.type, propertyInfo.format, undefined, '');
        }
      } else {
        if (_.isUndefined(propertyValue)) {
          return;
        }
      }

      const validatedPropertyValue = validateValue(propertyName, propertyInfo.type, propertyInfo.format, propertyInfo['x-nullable'], propertyInfo.items, propertyInfo.properties, propertyInfo.required, propertyValue);
      actualValue[propertyName] = validatedPropertyValue;
    });
    return actualValue;
  }

  function validateValue(name, type, format, nullable, items, properties, required, value) {
    if (_.isNull(value)){
      if (!_.isUndefined(nullable)) {
        if (nullable) {
          return null;
        } else {
          throw new NullableValidationError(name, type, format, value, `Can not be 'null'.`);
        }
      }
    }

    return validateValueType(name, type, format, nullable, items, properties, required, value);
  }

  function validateValueType(name, type, format, nullable, items, properties, required, value) {
    switch (type) {
      case 'boolean':
        return validateBoolean(name, type, format, value);
      case 'integer':
        return validateInteger(name, type, format, value);
      case 'number':
        return validateNumber(name, type, format, value);
      case 'string':
        return validateString(name, type, format, value);
      case 'object':
        return validateObject(name, type, format, properties, required, value);
      case 'array':
        return validateArray(name, type, format, items, value);
      default:
        throw new TypeValidationError(name, type, format, value, `Unsupported type: '${type}'.`);
    }
  }

  function validateSchema(name, type, format, schema, value) {
    if (!schema) {
      throw new TypeValidationError(name, type, format, value, `Schema not found.`);
    }

    switch (schema.type) {
      case 'object':
        return validateObject(name, type, format, schema.properties, schema.required, value);
      case 'array':
        return validateArray(name, type, format, schema.items, value);
      default:
        throw new TypeValidationError(name, type, format, value, `Unsupported schema type: '${schema.type}'.`);
    }
  }

  function validateParameter(parameterDefinition, parameterValue) {
    try {
      if (parameterDefinition.required) {
        if (_.isUndefined(parameterValue)) {
          throw new RequiredValidationError(parameterDefinition.name, parameterDefinition.type, parameterDefinition.format, undefined, '');
        }
      } else {
        if (_.isUndefined(parameterValue)) {
          return parameterValue;
        }
      }

      if (parameterDefinition.in === 'body') {
        return validateSchema(parameterDefinition.name, parameterDefinition.type, parameterDefinition.format, parameterDefinition.schema, parameterValue);
      } else {
        return validateValue(parameterDefinition.name, parameterDefinition.type, parameterDefinition.format, parameterDefinition['x-nullable'], parameterDefinition.items, undefined, undefined, parameterValue);
      }
    } catch (err) {
      throw new ParameterValidationError(parameterDefinition, parameterValue, err);
    }
  }

  function getParameterValue(ctx, parameterDefinition) {
    const parameterSource = getRequestParameterSource(ctx, parameterDefinition);
    switch (parameterDefinition.in) {
      case 'body':
        return parameterSource;
      default:
        return parameterSource[parameterDefinition.name];
    }
  }

  function setParameterValue(ctx, parameterDefinition, validatedParameterValue) {
    if (parameterDefinition.in === 'body') {
      ctx.request.body = validatedParameterValue;
    } else {
      const parameterSource = getRequestParameterSource(ctx, parameterDefinition);
      parameterSource[parameterDefinition.name] = validatedParameterValue;
    }
  }

  function getRequestParameterSource(ctx, parameterDefinition) {
    switch (parameterDefinition.in) {
      case 'path':
        return ctx.params;
      case 'query':
        return ctx.query;
      case 'body':
        return ctx.request.body;
      case 'formData':
        return ctx.request.body;
      default:
        throw new ParameterValidationError(parameterDefinition, undefined, new SourceValidationError(parameterDefinition.name, parameterDefinition.type, parameterDefinition.format, undefined, `Unknown source: '${parameterDefinition.in}'.`));
    }
  }

  function validateParameters(ctx) {
    _.forEach(parameterDefinitions, parameterDefinition => {
      const parameterValue = getParameterValue(ctx, parameterDefinition);
      const validatedParameterValue = validateParameter(parameterDefinition, parameterValue);
      setParameterValue(ctx, parameterDefinition, validatedParameterValue);
    });
  }

  return function* validateRequest(next) {
    try {
      validateParameters(this);

      yield next;
    } catch (err) {
      debug(err);

      this.status = HTTPStatus.BAD_REQUEST;
      this.body = {
        code      : err.code,
        message   : err.message,
        route     : {
          method : method,
          path   : route
        },
        parameter : {
          expected : err.parameterDefinition,
          actual   : err.parameterValue
        }
      }
    }
  };
}