BigstickCarpet/swagger-express-middleware

View on GitHub
lib/helpers/json-schema.js

Summary

Maintainability
F
1 wk
Test Coverage
"use strict";

module.exports = JsonSchema;

const tv4 = require("tv4");
const ono = require("@jsdevtools/ono");
const _ = require("lodash");

// Supported data types
let dataTypes = ["string", "number", "integer", "boolean", "array", "object", "file"];

// Valid patterns for each data type
let dataTypePatterns = {
  integer: /^[+-]?(\d+|0x[\dA-F]+)$/i,

  date: /^\d{4}-\d{2}-\d{2}$/,

  "date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/i
};

// Some older versions of Node don't define these constants
let MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991;
let MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
let MAX_VALUE = Number.MAX_VALUE || 1.7976931348623157e+308;
let MIN_VALUE = -MAX_VALUE;
let EPSILON = Number.EPSILON || 2.220446049250313e-16;

// Numeric type ranges
let ranges = {
  int32: {
    min: -2147483648,
    max: 2147483647
  },

  int64: {
    min: MIN_SAFE_INTEGER,
    max: MAX_SAFE_INTEGER
  },

  byte: {
    min: 0,
    max: 255
  },

  float: {
    min: -3.402823e38,
    max: 3.402823e38
  },

  double: {
    min: MIN_VALUE,
    max: MAX_VALUE
  }
};

/**
 * Parses and validates values against JSON schemas.
 *
 * @constructor
 */
function JsonSchema (schema) {
  if (!schema) {
    throw ono({ status: 500 }, "Missing JSON schema");
  }
  if (schema.type !== undefined && dataTypes.indexOf(schema.type) === -1) {
    throw ono({ status: 500 }, "Invalid JSON schema type: %s", schema.type);
  }

  this.schema = schema;
}

/**
 * Parses the given value according to the schema.
 * An error is thrown if the value is invalid.
 *
 * @param   {*}         value      - The value to be parsed against the schema
 * @param   {string}    [propPath] - Used only for logging and error messages (e.g. "person.address.city")
 * @returns {*}                    - The valid, parsed value
 */
JsonSchema.prototype.parse = function (value, propPath) {
  switch (this.schema.type) {
    case "number":
      return parseNumber(this.schema, value, propPath);
    case "integer":
      return parseInteger(this.schema, value, propPath);
    case "boolean":
      return parseBoolean(this.schema, value, propPath);
    case "array":
      return parseArray(this.schema, value, propPath);
    case "object":
    case undefined:
      return parseObject(this.schema, value, propPath);
    case "file":
      return parseFile(this.schema, value, propPath);
    case "string":
      switch (this.schema.format) {
        case "byte":
          return parseInteger(this.schema, value, propPath);
        case "date":
        case "date-time":
          return parseDate(this.schema, value, propPath);
        default:
          return parseString(this.schema, value, propPath);
      }
  }
};

/**
 * Serializes the given value according to the schema.
 * An error is thrown if the value is invalid.
 *
 * @param   {*}         value      - The value to be serialized.
 * @param   {string}    [propPath] - Used only for logging and error messages (e.g. "person.address.city")
 * @returns {*}                    - The serialized value, suitable for persisting or transmitting
 */
JsonSchema.prototype.serialize = function (value, propPath) {
  switch (this.schema.type) {
    case "number":
      return serializeNumber(this.schema, value, propPath);
    case "integer":
      return serializeInteger(this.schema, value, propPath);
    case "boolean":
      return serializeBoolean(this.schema, value, propPath);
    case "array":
      return serializeArray(this.schema, value, propPath);
    case "object":
    case undefined:
      return serializeObject(this.schema, value, propPath);
    case "file":
      return serializeFile(this.schema, value, propPath);
    case "string":
      switch (this.schema.format) {
        case "byte":
          return serializeInteger(this.schema, value, propPath);
        case "date":
        case "date-time":
          return serializeDate(this.schema, value, propPath);
        default:
          return serializeString(this.schema, value, propPath);
      }
  }
};

/**
 * Generates sample data from the schema.
 *
 * @returns {*}
 */
JsonSchema.prototype.sample = function () {
  switch (this.schema.type) {
    case "number":
      return sampleNumber(this.schema);
    case "integer":
      return sampleInteger(this.schema);
    case "boolean":
      return sampleBoolean(this.schema);
    case "array":
      return sampleArray(this.schema);
    case "object":
    case undefined:
      return sampleObject(this.schema);
    case "string":
      switch (this.schema.format) {
        case "byte":
          return sampleInteger(this.schema);
        case "date":
        case "date-time":
          return sampleDate(this.schema);
        default:
          return sampleString(this.schema);
      }
  }
};

/**
 * Returns the given value, or the default value if the given value is empty.
 */
function getValueToValidate (schema, value) {
  // Is the value empty?
  if (value === undefined || value === "" ||
    (schema.type === "object" && _.isObject(value) && _.isEmpty(value))) {

    // It's blank, so return the default/example value (if there is one)
    if (schema.default !== undefined) {
      value = schema.default;
    }
    else if (schema.example !== undefined) {
      value = schema.example;
    }
  }

  // Special case for Buffers
  if (value && value.type === "Buffer" && _.isArray(value.data)) {
    value = new Buffer(value.data);
  }

  // It's not empty, so return the existing value
  return value;
}

function jsonValidate (schema, value, propPath) {
  if (tv4.validate(value, schema)) {
    return true;
  }
  else if (propPath !== undefined) {
    let dataPath = propPath ? propPath + "." + tv4.error.dataPath : tv4.error.dataPath;
    throw ono(tv4.error, { status: 400 }, 'JSON Schema validation error. \nData path: "%s" \nSchema path: "%s"',
      dataPath, tv4.error.schemaPath);
  }
  else {
    throw ono(tv4.error, { status: 400 }, "JSON Schema validation error.");
  }
}

function parseInteger (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // Make sure it's a properly-formatted integer
  let parsedValue = parseInt(value);
  if (_.isNaN(parsedValue) || !_.isFinite(parsedValue) || !dataTypePatterns.integer.test(value)) {
    throw ono({ status: 400 }, '"%s" is not a properly-formatted whole number', propPath || value);
  }

  // Force the schema to be validated as an integer
  let originalType = schema.type;
  schema.type = "integer";

  // Validate against the schema
  try {
    jsonValidate(schema, parsedValue, propPath);
  }
  finally {
    // Restore the original schema type
    schema.type = originalType;
  }

  // Validate the format
  let range = ranges[schema.format];
  if (range) {
    if (parsedValue < range.min || parsedValue > range.max) {
      throw ono({ status: 400 }, '"%s" is not a valid %s. Must be between %d and %d',
        propPath || parsedValue, schema.format, range.min, range.max);
    }
  }

  return parsedValue;
}

function serializeInteger (schema, value, propPath) {
  value = getValueToValidate(schema, value);
  if (value !== undefined) {
    return parseInt(value);
  }
}

function sampleInteger (schema) {
  let min, max;
  if (schema.minimum !== undefined) {
    min = parseInt(schema.minimum) + (schema.exclusiveMinimum ? 1 : 0);
  }
  else {
    min = Math.min(1, schema.maximum - 1) || 1;
  }

  if (schema.maximum !== undefined) {
    max = parseInt(schema.maximum) - (schema.exclusiveMaximum ? 1 : 0);
  }
  else {
    max = (ranges[schema.format] || ranges.int64).max;
  }

  return _.random(min, max);
}

function parseNumber (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // Make sure it's a properly-formatted number
  let parsedValue = value === "" ? NaN : Number(value);
  if (_.isNaN(parsedValue) || !_.isFinite(parsedValue)) {
    throw ono({ status: 400 }, '"%s" is not a valid numeric value', propPath || value);
  }

  // Validate against the schema
  jsonValidate(schema, parsedValue, propPath);

  // Validate the format
  let range = ranges[schema.format];
  if (range) {
    if (parsedValue < range.min || parsedValue > range.max) {
      throw ono({ status: 400 }, '"%s" is not a valid %s. Must be between %d and %d',
        propPath || value, schema.format, range.min, range.max);
    }
  }

  return parsedValue;
}

function serializeNumber (schema, value, propPath) {
  value = getValueToValidate(schema, value);
  if (value !== undefined) {
    return parseFloat(value);
  }
}

function sampleNumber (schema) {
  let min, max;
  if (schema.minimum !== undefined) {
    min = parseFloat(schema.minimum) + (schema.exclusiveMinimum ? EPSILON : 0);
  }
  else {
    min = Math.min(0, schema.maximum) || 0;
  }

  if (schema.maximum !== undefined) {
    max = parseFloat(schema.maximum) - (schema.exclusiveMaximum ? EPSILON : 0);
  }
  else {
    max = (ranges[schema.format] || ranges.double).max;
  }

  return _.random(min, max);
}

function parseBoolean (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // "Parse" the value
  let parsedValue = value;
  let stringValue = _(value).toString().toLowerCase();
  if (stringValue === "true") {
    parsedValue = true;
  }
  else if (stringValue === "false") {
    parsedValue = false;
  }

  // Validate against the schema
  jsonValidate(schema, parsedValue, propPath);

  return !!parsedValue;
}

function serializeBoolean (schema, value, propPath) {
  value = getValueToValidate(schema, value);
  if (value !== undefined) {
    return !!value;
  }
}

function sampleBoolean (schema) {
  return _.random() % 2 === 0;
}

function parseString (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // Validate against the schema
  jsonValidate(schema, value, propPath);

  return value;
}

function serializeString (schema, value, propPath) {
  value = getValueToValidate(schema, value);
  if (value !== undefined) {
    return _(value).toString();
  }
}

function sampleString (schema) {
  let minLength, maxLength;
  if (schema.minLength !== undefined) {
    minLength = parseInt(schema.minLength);
  }
  else {
    minLength = Math.min(1, schema.maxLength) || 1;
  }

  if (schema.maxLength !== undefined) {
    maxLength = parseInt(schema.maxLength);
  }
  else {
    maxLength = Math.max(50, minLength);
  }

  let charSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  let str = "";
  let length = _.random(minLength, maxLength);
  for (let i = 0; i < length; i++) {
    str += charSet[_.random(0, charSet.length - 1)];
  }

  return str;
}

function parseArray (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  let parsedValue = value;

  if (_.isString(value) && value.length) {
    // Parse the string to an array
    switch (schema.collectionFormat) {
      case "ssv":
        parsedValue = value.split(" ");
        break;
      case "tsv":
        parsedValue = value.split("\t");
        break;
      case "pipes":
        parsedValue = value.split("|");
        break;
      default: // csv
        parsedValue = value.split(",");
    }
  }

  if (schema.items.type === "string") {
    // Validate the array against the schema BEFORE parsing the items
    jsonValidate(schema, parsedValue, propPath);
  }

  // Parse the items in the array
  parseArrayItems(schema, parsedValue, propPath);

  if (schema.items.type !== "string") {
    // Validate the array against the schema AFTER parsing the items
    jsonValidate(schema, parsedValue, propPath);
  }

  return parsedValue;
}

function parseArrayItems (schema, array, propPath) {
  let itemSchema = new JsonSchema(schema.items);

  for (let i = 0; i < array.length; i++) {
    let item = array[i];
    try {
      array[i] = itemSchema.parse(item, propPath + "[" + i + "]");
    }
    catch (e) {
      throw ono(e, { status: 400 }, "Unable to parse %s item at index %d (%j).", propPath || "array", i, item);
    }
  }
}

function serializeArray (schema, value, propPath) {
  value = getValueToValidate(schema, value);

  if (_.isArray(value) && schema.items) {
    let itemSchema = new JsonSchema(schema.items);

    for (let i = 0; i < value.length; i++) {
      value[i] = itemSchema.serialize(value[i], propPath + "[" + i + "]");
    }
  }

  return value;
}

function sampleArray (schema) {
  let minItems, maxItems;
  if (schema.minItems !== undefined) {
    minItems = parseInt(schema.minItems);
  }
  else {
    minItems = Math.min(1, schema.maxItems || 1);
  }

  if (schema.maxItems !== undefined) {
    maxItems = parseInt(schema.maxItems);
  }
  else {
    maxItems = Math.max(50, minItems);
  }

  let array = [];
  let itemSchema = new JsonSchema(schema.items);
  let length = _.random(minItems, maxItems);
  for (let i = 0; i < length; i++) {
    array.push(itemSchema.sample());
  }

  return array;
}

function parseObject (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // Parse the value
  let parsedValue = value;
  if (_.isString(value) && value.length) {
    parsedValue = JSON.parse(value);
  }

  // Validate against the schema
  jsonValidate(schema, parsedValue, propPath || "");

  // Recursively parse the object's properties
  _.forEach(schema.properties, (prop, propName) => {
    if (parsedValue[propName] !== undefined || prop.default !== undefined) {
      let propSchema = new JsonSchema(prop);
      let fullPropPath = propPath ? propPath + "." + propName : propName;
      parsedValue[propName] = propSchema.parse(parsedValue[propName], fullPropPath);
    }
  });

  return parsedValue;
}

function serializeObject (schema, value, propPath) {
  value = getValueToValidate(schema, value);

  if (value) {
    _.forEach(schema.properties, (prop, propName) => {
      let propSchema = new JsonSchema(prop);
      let fullPropPath = propPath ? propPath + "." + propName : propName;
      let propValue = propSchema.serialize(value[propName], fullPropPath);
      if (propValue !== undefined) {
        value[propName] = propValue;
      }
    });
  }

  return value;
}

function sampleObject (schema) {
  let obj = {};
  _.keys(schema.properties).forEach((propName) => {
    let propSchema = new JsonSchema(schema.properties[propName]);
    obj[propName] = propSchema.sample();
  });

  return obj;
}

function parseFile (schema, value, propPath) {
  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // Parse the value
  let parsedValue = value;
  if (!_.isObject(parsedValue) || !_.has(parsedValue, "size")) {
    throw ono({ status: 400 }, "%s is invalid or corrupted", propPath || "File");
  }

  // Validate minLength and maxLength
  if (schema.minLength) {
    let minLength = parseInt(schema.minLength);
    if (isNaN(minLength)) {
      throw ono({ status: 500 }, 'The "minLength" value in the Swagger API is invalid (%j)', schema.minLength);
    }

    if (parsedValue.size < minLength) {
      throw ono({ status: 400 }, '%s "%s" is only %d bytes. The minimum is %d bytes',
        propPath || "File", parsedValue.originalname, parsedValue.size, minLength);
    }
  }

  if (schema.maxLength) {
    let maxLength = parseInt(schema.maxLength);
    if (isNaN(maxLength)) {
      throw ono({ status: 500 }, 'The "maxLength" value in the Swagger API is invalid (%j)', schema.maxLength);
    }

    if (parsedValue.size > maxLength) {
      throw ono({ status: 413 }, '%s "%s" is %d bytes. The maximum is %d bytes',
        propPath || "File", parsedValue.originalname, parsedValue.size, maxLength);
    }
  }

  return parsedValue;
}

function serializeFile (schema, value, propPath) {
  value = getValueToValidate(schema, value);

  // Search for a property that looks like a File object
  let keys = _.keys(value);
  for (let i = 0; i < keys.length; i++) {
    let file = value[keys[i]];
    if (file && file.path && _.isString(file.path)) {
      // This is the File object, so return it
      return file;
    }
  }

  // None of the properties was a File object, so assume that the value itself is the file object
  return value;
}

function parseDate (schema, value, propPath) {
  let parsedValue;

  // Handle missing, required, and default
  value = getValueToValidate(schema, value);

  // If the value is already a Date, then we can skip some validation
  if (_.isDate(value)) {
    parsedValue = value;
  }
  else {
    // Validate against the schema
    jsonValidate(schema, value, propPath);

    // Validate the format
    let formatPattern = dataTypePatterns[schema.format];
    if (!formatPattern.test(value)) {
      throw ono({ status: 400 }, '"%s" is not a properly-formatted %s', propPath || value, schema.format);
    }

    // Parse the date
    parsedValue = new Date(value);
    if (!parsedValue || isNaN(parsedValue.getTime())) {
      throw ono({ status: 400 }, '"%s" is an invalid %s', propPath || value, schema.format);
    }
  }

  if (schema.minimum) {
    let minDate = new Date(schema.minimum);
    if (isNaN(minDate.getTime())) {
      throw ono({ status: 500 }, 'The "minimum" value in the Swagger API is invalid (%j)', schema.minimum);
    }

    if (parsedValue < minDate) {
      throw ono({ status: 400 }, "%s (%j) is less than minimum %j", propPath || "Value", parsedValue, minDate);
    }

    if (schema.exclusiveMinimum === true) {
      if (parsedValue.getTime() === minDate.getTime()) {
        throw ono({ status: 400 }, "%s (%j) is equal to exclusive minimum %j", propPath || "Value", parsedValue, minDate);
      }
    }
  }

  if (schema.maximum) {
    let maxDate = new Date(schema.maximum);
    if (isNaN(maxDate.getTime())) {
      throw ono({ status: 500 }, 'The "maximum" value in the Swagger API is invalid (%j)', schema.maximum);
    }

    if (parsedValue > maxDate) {
      throw ono({ status: 400 }, "%s (%j) is greater than maximum %j", propPath || "Value", parsedValue, maxDate);
    }

    if (schema.exclusiveMaximum === true) {
      if (parsedValue.getTime() === maxDate.getTime()) {
        throw ono({ status: 400 }, "%s (%j) is equal to exclusive maximum %j", propPath || "Value", parsedValue, maxDate);
      }
    }
  }

  return parsedValue;
}

function serializeDate (schema, value, propPath) {
  value = getValueToValidate(schema, value);

  if (schema.format === "date" && value) {
    // This works regardless of whether the value is a Date or an ISO8601 string
    return JSON.stringify(value).substring(1, 11);
  }
  else if (_.isDate(value)) {
    return value.toJSON();
  }
  else if (value) {
    return _(value).toString();
  }
}

function sampleDate (schema) {
  let min, max;
  if (schema.minimum !== undefined) {
    min = parseInt(new Date(schema.minimum).valueOf()) + (schema.exclusiveMinimum ? 1 : 0);
  }
  else {
    min = Date.UTC(1970, 0, 1);
    min = Math.min(min, new Date(schema.maximum).valueOf()) || min;
  }

  if (schema.maximum !== undefined) {
    max = parseInt(new Date(schema.maximum).valueOf()) - (schema.exclusiveMaximum ? 1 : 0);
  }
  else {
    max = Math.max(Date.now(), min);
  }

  let date = new Date(_.random(min, max));

  if (schema.format === "date") {
    // Only return the year/month/day (in UTC)
    date = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
  }

  return date;
}