BigstickCarpet/swagger-express-middleware

View on GitHub
lib/mock/edit-collection.js

Summary

Maintainability
C
7 hrs
Test Coverage
"use strict";

module.exports = {
  POST: mergeCollection,
  PATCH: mergeCollection,
  PUT: overwriteCollection
};

const _ = require("lodash");
const path = require("path");
const util = require("../helpers/util");
const Resource = require("../data-store/resource");
const JsonSchema = require("../helpers/json-schema");

/**
 * Adds one or more REST resources to the collection, or updates them if they already exist.
 * A unique URL is generated for each new resource, based on the schema definition in the Swagger API,
 * and this URL is used to determine whether a given resource is being created or updated.
 *
 * For example, if you POST the data {id: 123, name: 'John Doe'} to /api/users,
 * then the "id" property will be used to construct the new REST URL: /api/users/123
 *
 * Similarly, if you POST the data {name: 'John Doe', age: 42} to /api/users,
 * then the "name" property will be used to construct the new URL: /api/users/John%20Doe
 *
 * If the data doesn't contain any properties that seem like unique IDs, then a unique ID is generated,
 * which means new resources will always be created, and never updated.
 *
 * @param   {Request}   req
 * @param   {Response}  res
 * @param   {function}  next
 * @param   {DataStore} dataStore
 */
function mergeCollection (req, res, next, dataStore) {
  let collection = req.path;
  let resources = createResources(req);

  // Set the "Location" HTTP header.
  // If the operation allows saving multiple resources, then use the collection path.
  // If the operation only saves a single resource, then use the resource's path.
  res.swagger.location = _.isArray(req.body) ? collection : resources[0].toString();

  // Save/Update the resources
  util.debug("Saving data at %s", res.swagger.location);
  dataStore.save(resources, sendResponse(req, res, next, dataStore));
}

/**
 * Adds one or more REST resources to the collection, or overwrites them if they already exist.
 * A unique URL is generated for each new resource, based on the schema definition in the Swagger API,
 * and this URL is used to determine whether a given resource is being created or overwritten.
 *
 * For example, if you POST the data {id: 123, name: 'John Doe'} to /api/users,
 * then the "id" property will be used to construct the new REST URL: /api/users/123
 *
 * Similarly, if you POST the data {name: 'John Doe', age: 42} to /api/users,
 * then the "name" property will be used to construct the new URL: /api/users/John%20Doe
 *
 * If the data doesn't contain any properties that seem like unique IDs, then a unique ID is generated,
 * which means new resources will always be created, and never overwritten.
 *
 * @param   {Request}   req
 * @param   {Response}  res
 * @param   {function}  next
 * @param   {DataStore} dataStore
 */
function overwriteCollection (req, res, next, dataStore) {
  let collection = req.path;
  let resources = createResources(req);

  // Set the "Location" HTTP header.
  // If the operation allows saving multiple resources, then use the collection path.
  // If the operation only saves a single resource, then use the resource's path.
  res.swagger.location = _.isArray(req.body) ? collection : resources[0].toString();

  // Delete the existing resources
  dataStore.delete(resources, (err) => {
    if (err) {
      next(err);
    }
    else {
      // Save the new resources
      util.debug("Saving data at %s", res.swagger.location);
      dataStore.save(resources, sendResponse(req, res, next, dataStore));
    }
  });
}

/**
 * Creates {@link Resource} objects for each resource in the request
 *
 * @param   {Request}   req
 * @returns {Resource[]}
 */
function createResources (req) {
  let resources = [],
      body = req.body,
      schema = util.getRequestSchema(req.swagger.path, req.swagger.operation);

  if (!_.isArray(body)) {
    if (!_.isEmpty(req.files)) {
      // Save file data too
      body = _.extend({}, req.body, req.files);
    }

    // Normalize to an array
    body = [body];
  }

  // Create a REST resource for each item in the array
  for (let i = 0; i < body.length; i++) {
    let data = body[i];

    // Determine the resource's "Name" property
    let propInfo = getResourceName(data, schema);

    if (propInfo.name) {
      // Update the data, so the new name is saved with the resource data
      data = data || {};
      if (data[propInfo.name] === undefined) {
        data[propInfo.name] = propInfo.value;
        body[i] = data;
      }
    }

    // Create a resource name that is a safe URL string (2000 character max)
    let resourceName = new JsonSchema(propInfo.schema).serialize(propInfo.value);
    resourceName = _(resourceName).toString().substring(0, 2000);
    resourceName = encodeURIComponent(resourceName);

    // Create a REST resource
    let resource = new Resource(req.path, resourceName, data);
    resources.push(resource);
  }

  return resources;
}

/**
 * Returns the property that is the REST resource's "unique" name.
 *
 * @param   {*}         data   - The parsed resource data.
 * @param   {object}    schema - The JSON schema for the data.
 * @returns {PropertyInfo}     - The resource's name.
 */
function getResourceName (data, schema) {
  // Try to find the "name" property using several different methods
  let propInfo =
        getResourceNameByValue(data, schema) ||
        getResourceNameByName(data, schema) ||
        getResourceNameByRequired(data, schema) ||
        getResourceNameByFile(data, schema);

  if (propInfo) {
    util.debug('The "%s" property (%j) appears to be the REST resource\'s name', propInfo.name, propInfo.value);

    if (propInfo.value === undefined) {
      propInfo.value = new JsonSchema(propInfo.schema).sample();
      util.debug('Generated new value (%j) for the "%s" property', propInfo.value, propInfo.name);
    }

    return propInfo;
  }
  else {
    util.debug("Unable to determine the unique name for the REST resource. Generating a unique value instead");
    return {
      name: "",
      schema: { type: "string" },
      value: _.uniqueId()
    };
  }
}

/**
 * If the REST resource is a simple value (number, string, date, etc.),
 * then the value is returned as the resource's name.
 *
 * @param   {*}         data   - The parsed resource data.
 * @param   {object}    schema - The JSON schema for the data.
 * @returns {PropertyInfo|undefined}
 */
function getResourceNameByValue (data, schema) {
  if (schema.type !== "object" && schema.type !== undefined) {
    // The resource is a simple value, so just return the raw data as the "name"
    return {
      name: "",
      schema,
      value: data
    };
  }
}

/**
 * Tries to find the REST resource's name by searching for commonly-used property names like "id", "key", etc.
 *
 * @param   {*}         data   - The parsed resource data.
 * @param   {object}    schema - The JSON schema for the data.
 * @returns {PropertyInfo|undefined}
 */
function getResourceNameByName (data, schema) {
  /** @name PropertyInfo */
  let propInfo = {
    name: "",
    schema: {
      type: ""
    },
    value: undefined
  };

  // Get a list of all existing and possible properties of the resource
  let propNames = _.union(_.keys(schema.properties), _.keys(data));

  // Lowercase property names, for comparison
  let lowercasePropNames = propNames.map((propName) => { return propName.toLowerCase(); });

  // These properties are assumed to be unique IDs.
  // If we find any of them in the schema, then use it as the REST resource's name.
  let nameProperties = ["id", "key", "slug", "code", "number", "num", "nbr", "username", "name"];
  let foundMatch = nameProperties.some((lowercasePropName) => {
    let index = lowercasePropNames.indexOf(lowercasePropName);
    if (index >= 0) {
      // We found a property that appears to be the resource's name. Get its info.
      propInfo.name = propNames[index];
      propInfo.value = data ? data[propInfo.name] : undefined;

      if (schema.properties[propInfo.name]) {
        propInfo.schema = schema.properties[propInfo.name];
      }
      else if (_.isDate(data[propInfo.name])) {
        propInfo.schema = {
          type: "string",
          format: "date-time"
        };
      }
      else {
        propInfo.schema.type = typeof (data[propInfo.name]);
      }

      // If the property is valid, then we're done
      return isValidResourceName(propInfo);
    }
  });

  return foundMatch ? propInfo : undefined;
}

/**
 * Tries to find the REST resource's name using the required properties in the JSON schema.
 * We're assuming that if the resource has a name, it'll be a required property.
 *
 * @param   {*}         data   - The parsed resource data.
 * @param   {object}    schema - The JSON schema for the data.
 * @returns {PropertyInfo|undefined}
 */
function getResourceNameByRequired (data, schema) {
  let propInfo = {
    name: "",
    schema: {
      type: ""
    },
    value: undefined
  };

  let foundMatch = _.some(schema.required, (propName) => {
    propInfo.name = propName;
    propInfo.schema = schema.properties[propName];
    propInfo.value = data[propName];

    // If the property is valid, then we're done
    return isValidResourceName(propInfo);
  });

  return foundMatch ? propInfo : undefined;
}

/**
 * If the REST resource contains a file (e.g. multipart/form-data or application/x-www-form-urlencoded),
 * then we'll use the file name as the resource name.
 *
 * @param   {*}         data   - The parsed resource data.
 * @param   {object}    schema - The JSON schema for the data.
 * @returns {PropertyInfo|undefined}
 */
function getResourceNameByFile (data, schema) {
  // Find all file parameters
  let files = _.filter(schema.properties, { type: "file" });

  // If there is exactly ONE file parameter, then we'll use its file name
  if (files.length === 1) {
    let file = data[files[0].name];
    if (file && (file.originalname || file.path)) {
      return {
        name: file.fieldname,
        schema: {
          type: "string"
        },

        // Use the original file name, if provided. Otherwise, fallback to the server-side file name
        value: file.originalname || path.basename(file.path)
      };
    }
  }
}

/**
 * Determines whether the given property is a valid REST resource name.
 * Only simple types (strings, numbers, booleans) are used as keys.
 * Complex types (arrays, objects, files) are ignored.
 *
 * @param   {PropertyInfo}    propInfo
 * @returns {boolean}
 */
function isValidResourceName (propInfo) {
  let validTypes = ["string", "number", "integer", "boolean"];
  return validTypes.indexOf(propInfo.schema.type.toLocaleLowerCase()) >= 0;
}

/**
 * Returns a function that sends the correct response for the operation.
 *
 * @param   {Request}       req
 * @param   {Response}      res
 * @param   {function}      next
 * @param   {DataStore}     dataStore
 */
function sendResponse (req, res, next, dataStore) {
  return function (err, resources) {
    if (!err) {
      util.debug("%s successfully updated", res.swagger.location);

      if (resources.length > 0) {
        // Determine the max "modifiedOn" date of the resources
        res.swagger.lastModified = _.max(resources, "modifiedOn").modifiedOn;
      }
      else {
        // There is no data, so use the current date/time as the "last-modified" header
        res.swagger.lastModified = new Date();
      }

      // Extract the "data" of each Resource
      resources = _.map(resources, "data");
    }

    // Set the response body (unless it's already been set by other middleware)
    if (err || res.body) {
      next(err);
    }
    else if (!res.swagger.isCollection) {
      // Response body is a single value, so only return the first item that was edited
      res.body = _.first(resources);
      next();
    }
    else {
      // Response body is the entire collection (new, edited, and old)
      dataStore.getCollection(req.path, (err, collection) => {
        res.body = _.map(collection, "data");
        next(err);
      });
    }
  };
}