zackehh/oryx

View on GitHub
lib/routes.js

Summary

Maintainability
A
3 hrs
Test Coverage
// npm modules
var Bluebird = require('bluebird');
var define = require('lodash.set');
var merge = require('lodash.merge');

// local modules
var OryxError = require('./error');
var OryxResponse = require('./response');

// constants
const specialKeys = [
  'limit', 'skip', 'sort',
  'select', 'where'
];

/**
 * Export of all routes which may be defined for a
 * given model. All properties in this module are
 * keyed by the route to be given to Express, mapper
 * to a function value which is given a model instance
 * to use when creating the route on the app.
 *
 * @type {*}
 */
module.exports = {

  /**
   * Interacts with the model instances, using any
   * valid Waterline queries, provided either query
   * parameters or a request body. Limits default to
   * 10, in order to avoid accidentally querying the
   * entire instance set.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'GET /': function (model, req, res) {
    // parse any parameters
    parameters(req)
      // pass the parsed params to Waterline
      .then(model.find.bind(model))
      // use our default exit handler for a valid body
      .then(exitWithBody.bind(exitWithBody, res, 200))
      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Creates a model instance, using the request
   * body as the payload. This will be validated by
   * Waterline, and any necessary errors will be returned
   * to the user.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'POST /': function (model, req, res) {
    model
      // pass the request body to Waterline
      .create(req.body)
      // use our default exit handler for a valid body
      .then(exitWithBody.bind(exitWithBody, res, 201))
      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Updates a model instance, using the 'doc' field of
   * the request body as the payload. Updates will be
   * validated by Waterline.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'PUT /': function (model, req, res) {
    // ensure that we have a valid update document
    // to use alongside the update request
    if (typeof(req.body) !== 'object' || Object.keys(req.body).length === 0) {
      // create an error, requiring that the field
      // be included in any requests
      var err = new OryxError('No body provided!');
      // return the error to the user
      return exitWithError(res, 400, err);
    }

    // parse all params - updates are unlimited by default
    // in order to take advantage of query matching
    parameters(req, { limit: undefined })

      // pass all parameters to Waterline, and
      // pass the 'doc' field as the update document
      .then(function (params) {
        return model.update(params, req.body);
      })

      // then we take the response and return a count
      // containing the number of documents updated
      // by this request - it would be possible to return
      // all the documents, however this could easily
      // become a huge payload
      .then(function (docs) {

        // exit with a success response code, and a
        // basic count of updated documents
        exitWithBody(res, 200, {
          docs_updated: docs.length
        });
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Deletes model instances matching the given parameters.
   * Returns a count of the number of documents deleted.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'DELETE /': function (model, req, res) {
    // parse all params - deletes are unlimited by default
    // in order to take advantage of query matching
    parameters(req, { limit: undefined })

      // pass all parameters to Waterline - it can
      // do the heavy lifting for us
      .then(model.destroy.bind(model))

      // then we use the returned documents to
      // return a count of removed documents
      .then(function (docs) {

        // use our default exit handler, passing
        // a 200 response and the document count
        exitWithBody(res, 200, {
          docs_removed: docs.length
        });
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Returns a count of documents matching the provided
   * parameters. Parameters can be provided either via
   * query string or request body.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'GET /count': function (model, req, res) {
    // parse all params - counts are unlimited by default
    // in order to take advantage of query matching
    parameters(req, { limit: undefined })

      // pass all parameters to Waterline - it can
      // do the heavy lifting for us
      .then(model.count.bind(model))

      // we then return the count to the user,
      // based on the response from Waterline
      .then(function (count) {

        // use our default exit handler, passing
        // a 200 response and the document count
        exitWithBody(res, 200, {
          doc_count: count
        });
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Returns basic information about the model, currently
   * just the model name and the defined schema.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'GET /info': function (model, req, res) {
    // simply exit with a 200 response and some information
    // about the metadata of the model - namely the model
    // name and the schema definition of the model
    exitWithBody(res, 200, {
      name: model.name,
      schema: model._attributes,
      routes: model.routes
    });
  },

  /**
   * Finds distinct values for the provided field name,
   * potentially matching a query defined in either the
   * query parameters or the request body.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'GET /distinct/:field': function (model, req, res) {
    // distinct requests are unlimited by default -
    // we also merge over the desired field for inclusion
    parameters(req, { limit: undefined, select: [req.params.field] })

      // pass all parameters to Waterline - it can
      // do the heavy lifting for us
      .then(model.find.bind(model))

      // we receive a list of matching documents (with
      // the 'select' applied, so we translate to a list
      // of values in an array before returning
      .then(function (docs) {

        // translate the returned documents by
        // pulling out the value cared about - we then
        // filter to ensure that there are no duplicates
        var values = docs

          // return the field we care about from within
          // the document - there should only be one field
          // within the document in the first place
          .map(function (doc) {
            return doc[req.params.field];
          })

          // ensure that we're not duplicating unique
          // values inside our array of values
          .filter(function (value, index, self) {
            return self.indexOf(value) === index;
          });

        // exit with a 200 response code and the
        // calculated unique values
        exitWithBody(res, 200, values);
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Retrieves a document by it's document id. If the document
   * does not exist, a 404 response is returned alongside an
   * empty object.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'GET /:id': function (model, req, res) {
    model
      // lookup the document directly by
      // the primary key from path params
      .findOne(req.params.id)

      // then we return the document if it
      // exists, if not it's a 404 response
      .then(function (doc) {

        // short circuit
        if (!doc) {
          // return a 404 response and an empty
          // object if the document does not exist
          return exitWithBody(res, 404, {});
        }

        // return a 200 response alongside
        // the document
        exitWithBody(res, 200, doc);
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * The equivalent of findOrCreate, this will return a
   * document if it exists, or will create a new document
   * from the request body.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'POST /:id': function (model, req, res) {
    // define a new field of the primary key set to the value
    // defined in the path parameters, in order to force the
    // creation of the new document with the correct idenfier
    var body = define(
      req.body, '["' + model.primaryKey + '"]', req.params.id
    );

    model
      // pass the values through to Waterline - the second
      // parameter is an object which needs the primary key
      // merging across so we don't just create a new document
      .findOrCreate(req.params.id, body)
      // use our default exit handler for a valid body
      .then(exitWithBody.bind(exitWithBody, res, 200))
      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Updates a specific model instance, using the document
   * id. If the document does not exist, a 404 response will
   * be returned. Otherwise the updated document will be
   * returned.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'PUT /:id': function (model, req, res) {
    model
      // directly pass in the request body as
      // we cannot be sure what fields may or
      // may not be desired in the update
      .update(req.params.id, req.body)

      // then we do the typical - if the document
      // doesn't exist, we return a 404 response,
      // otherwise we return the updated document
      .then(function (doc) {

        // short circuit
        if (!doc || doc.length === 0) {
          // return a 404 response and an empty
          // object if the document does not exist
          return exitWithBody(res, 404, {});
        }

        // return a 200 response alongside the doc
        exitWithBody(res, 200, doc[0]);
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  },

  /**
   * Deletes an instance of a model by the document id.
   * If the model does not exist, a 404 response is
   * returned.
   *
   * @param model the model for this request
   * @param req the request object
   * @param res the response object
   */
  'DELETE /:id': function (model, req, res) {
    model
      // simply pass the document id along to
      // Waterline, it can do it all for us
      .destroy(req.params.id)

      // then we do the typical - if the document
      // doesn't exist, we return a 404 response,
      // otherwise we return a count (similar to
      // mass deletion)
      .then(function (docs) {

        // short circuit
        if (!docs || docs.length === 0) {
          // return a 404 response and an empty
          // object if the document does not exist
          return exitWithBody(res, 404, {
            docs_removed: 0
          });
        }

        // otherwise we return just the count
        // of documents removed (this should always
        // be one)
        exitWithBody(res, 200, {
          docs_removed: docs.length
        });
      })

      // catch any errors with the default handler
      .catch(errorHandler(res));
  }

};

/**
 * Generates a callback closure to pass to the Promise
 * of all Waterline methods. This simply returns a callback
 * function for the Promise which wraps any error in a new
 * OryxError, and returns a 400 code.
 *
 * @param res
 * @returns {Function}
 */
function errorHandler(res) {
  // return a function to be used
  // as a callback
  return function (err) {
    // wrap the provided error in a OryxError, just to
    // keep all error handling consistent with the lib
    exitWithError(res, 400, new OryxError(err.message));
  };
}

/**
 * Shorthand to return an error to the user, simply
 * setting success to false, setting the status code,
 * and setting the error.
 *
 * @param res the response instance
 * @param code the status code
 * @param err the error instance
 */
function exitWithError(res, code, err) {
  // exit with the provided code
  // and response body
  exit(res, code, {
    success: false,
    statusCode: code,
    error: err
  });
}

/**
 * Shorthand to return a body to the user, simply
 * setting success to true, setting the status code,
 * and setting the body.
 *
 * @param res the response instance
 * @param code the status code
 * @param body the body object
 */
function exitWithBody(res, code, body) {
  // exit with the provided code
  // and response body
  exit(res, code, new OryxResponse(code, body));
}

/**
 * Simple shorthand for returning a status code
 * and response body to the user. This will
 * end the connection.
 *
 * @param res the response instance
 * @param code the status code
 * @param body the body object
 */
function exit(res, code, body) {
  // simply set the status, body and end
  res.status(code).json(body).end();
}

/**
 * Parses parameters from both the query parameters
 * and the request body, in order to normalize parameters
 * for Waterline requests used by almost all routes in
 * this file.
 *
 * @param req the request instance
 * @param def default overrides
 * @returns {*} an options object
 */
function parameters(req, def) {

  // create and return a new Bluebird Promise
  // object to allow chaining onto this method
  return new Bluebird(function (resolve, reject) {
    // create a set of defaults to be
    // used if not overridden
    var params = {
      limit: 10,
      skip: 0,
      sort: {},
      where: {}
    };

    // if we have defaults passed into this
    // function, we need to override the defaults
    if (typeof def === 'object') {
      // for every key in the passed-in defaults,
      // we overwrite the entry in the defaults object
      Object.keys(def).forEach(function (key) {
        params[key] = def[key];
      });
    }

    // small function to parse any values into
    // JSON, if they are already JSON strings
    function parse(val) {
      // determine if we have a string value
      // and if so, attempt to parse JSON
      if (typeof(val) === 'string') {
        try {
          return JSON.parse(val);
        } catch(e) { }
      }
      // otherwise simply
      // return input
      return val;
    }

    // we want to use a custom 'where' clause as
    // a start point - this allows merging of filters
    // which might not already be inside this object
    if (req.query.hasOwnProperty('where')) {
      params.where = parse(req.query.where);
    }

    // iterate through the keys, merging them into
    // the parameters object, and attempting to parse
    // any strings in order to accept JSON payloads
    Object.keys(req.query).forEach(function (key) {
      // grab the value we're working on
      var val = parse(req.query[key]);

      // if this isn't a reserved key, we add it
      // into the 'where' object as a query filter
      if (specialKeys.indexOf(key) === -1) {
        params.where[key] = val;
        return;
      }

      // special case the 'select' key
      if (key === 'select') {
        // the 'select' value should always
        // be an array, so we coerce as needed
        if (!(val instanceof Array)) {
          val = [val];
        }
      }

      // we don't want to overwrite
      // the 'where' accidentally
      if (key !== 'where') {
        params[key] = val;
      }
    });

    // ensure that the 'where' param is a valid Object
    // or an Array - this is a special compatibility piece
    // to deal with Object.keys changes between ES5 -> ES6
    if (typeof(params.where) !== 'object') {

      // prepare an error message in case
      // we need to exit the Promise chain
      var err = new OryxError(
        'Invalid \'where\' parameter specified!'
      );

      // reject due to having an
      // invalid where clause
      return reject(err);
    }

    // pass back the newly
    // validated params
    resolve(merge({ }, params));
  });

}