app/lib/jsonapi.js

/**
 * Generates routes conformant to the JSON API specification for Mongoose models
 * @module
 */

var _ = require('lodash');
var async = require('async');
var bodyParser = require('body-parser');
var debug = require('debug')('syncServer:jsonapi');
var logger = require('app/lib/logger');
var models = require('app/models');
var ObjectId = require('mongoose').Types.ObjectId;
var validateParams = require('./validateParams');

module.exports = function(app) {
  return {
    /**
     * Returns allowed requester type value for model and method
     * @param {Object} model - Mongoose model
     * @param {string} method - HTTP method
     * @returns {string} allowed value (e.g. "public", "user", or "admin)
     */
    allowed: function(model, method) {
      if (typeof model.jsonapi[method] === 'string') {
        return model.jsonapi[method];
      } else if (typeof model.jsonapi[method] === 'object') {
        return model.jsonapi[method].allowed;
      }
    },

    /**
     * Callbacks Mongoose query conditions compiled from two separate conditions
     * Primary conditions are passed as parameter "conditions"
     * Secondary conditions are determined from model using route name
     * @param {Object} req - Express request object
     * @param {Object} conditions - Primary query conditions
     * @param {Object} model - Mongoose model (optional)
     * @param {string} method - HTTP method (optional)
     * @param {callback} done
     */
    compiledQueryConditions: function myself(req, conditions, model, method, done) {
      this.modelQueryConditions(req, model, method, (error, modelConditions) => {
        done(error, Object.assign({}, modelConditions, conditions));
      });
    },

    /**
     * Callbacks a model's query conditions given request user status
     * @param {Object} req - Express request object
     * @param {Object} model - Mongoose model
     * @param {string} method - HTTP method (optional)
     * @param {callback} done
     */
    modelQueryConditions: function(req, model, method, done) {
      validateParams([{
        name: 'req', variable: req, required: true
      }, {
        name: 'model', variable: model, required: true, requiredProperties: ['jsonapi']
      }, {
        name: 'method', variable: method, required: true
      }]);

      debug('modelQueryConditions %s, %s', model.modelId(), method);

      if (model.jsonapi[method] && model.jsonapi[method].queryConditions) {
        var queryConditions = model.jsonapi[method].queryConditions;

        if (typeof queryConditions === 'object' && (queryConditions.public || queryConditions.user || queryConditions.admin)) {
          if (!req.user && queryConditions.public) {
            done(undefined, queryConditions.public);
          } else if (req.user && req.user.admin && queryConditions.admin) {
            done(undefined, queryConditions.admin);
          } else if (req.user && !req.user.admin && queryConditions.user) {
            done(undefined, queryConditions.user);
          }
        } else if (typeof queryConditions === 'function') {
          model.jsonapi[method].queryConditions(req, done);
        } else {
          done(undefined, queryConditions);
        }
      } else {
        done(undefined, {});
      }
    },

    /**
     * Normalizes object of relationships from request
     * @param {Object} relationships - Request relationships
     * @return {Object} Normalized relationships
     */
    normalizeRelationships: function(relationships) {
      var relationships = Object.assign({}, relationships);

      // Remove any relationships with empty data properties
      for (key in relationships) {
        if (!relationships[key].data) {
          delete relationships[key];
        }
      }

      // Convert names to camelCase
      relationships = _.mapKeys(relationships, (value, key) => {
        return _.camelCase(key);
      });

      return relationships;
    },

    /**
     * Routes DELETE requests to resource for individual resource objects for app and Model
     * @param {Object} app - Express app
     * @param {Object} Model - Mongoose model
     */
    routeModelDeleteObjectResource: function(app, Model) {
      this.routeModelResource(app, Model, 'delete', '/' + _.kebabCase(Model.modelType()) + '/:id', (req, res) => {
        var getConditions = (done) => {
          this.compiledQueryConditions(req, { _id: req.params.id }, Model, 'delete', done);
        };

        var findOne = (conditions, done) => {
          Model.findOne(conditions, done);
        };

        async.waterfall([getConditions, findOne], function(error, document) {
          if (error) {
            res.sendError(error);
          } else if (!document) {
            res.sendNotFound();
          } else {
            document.remove(function(error) {
              if (error) {
                res.status(500).send();
              } else {
                res.status(204).send();
              }
            });
          }
        });
      });
    },

    /**
     * Routes GET requests to resource for individual resource objects for app and model
     * @param {Object} app - Express app
     * @param {model} model - Mongoose model
     */
    routeModelGetObjectResource: function(app, Model) {
      this.routeModelResource(app, Model, 'get', '/' + _.kebabCase(Model.modelType()) + '/:id', (req, res) => {
        var getConditions = (done) => {
          this.compiledQueryConditions(req, { _id: req.params.id }, Model, 'get', done);
        };

        var findOne = (conditions, done) => {
          debug('GET findOne %O', conditions);
          Model.findOne(conditions, done);
        };

        async.waterfall([getConditions, findOne], function(error, document) {
          if (error) {
            logger.error('Resource router failed to query for object', { model: Model.modelName, error: error.message });
            res.sendError();
          } else if (!document) {
            res.sendNotFound();
          } else {
            res.sendDocument(document);
          }
        });
      });
    },

    /**
     * Routes GET requests to resource for collections of resource objects for app and model
     * @param {Object} app - Express app
     * @param {model} model - Mongoose model
     */
    routeModelGetObjectsResource: function(app, Model) {
      this.routeModelResource(app, Model, 'get', '/' + _.kebabCase(Model.modelType()), (req, res) => {
        var compileConditions = (done) => {
          this.compiledQueryConditions(req, {}, Model, 'get', done);
        };

        var executeQuery = (conditions, done) => {
          var cursor = req.query.page && req.query.page.cursor ? req.query.page.cursor : null;
          var limit = req.query.page && req.query.page.limit ? req.query.page.limit : 25;
          var query = Model.find(conditions);
          var supportedSortAttributes = ['_id' , 'createdAt', 'updatedAt'];

          debug('GET %O', conditions);

          if (req.query.sort) {
            var sortAttributes = req.query.sort;
            var unsupportedSortAttributeFound = false;

            sortAttributes.split(',').forEach(function(sortAttribute) {
              var absoluteSortAttribute;

              if (sortAttribute.charAt(0) === '-') {
                absoluteSortAttribute = sortAttribute.substring(1);
              } else {
                absoluteSortAttribute = sortAttribute;
              }

              if (supportedSortAttributes.indexOf(absoluteSortAttribute) === -1) {
                unsupportedSortAttributeFound = true;
              }
            });

            if (unsupportedSortAttributeFound) {
              return res.status(400).send('Unsupported sort attribute provided');
            }

            query.sort(sortAttributes.replace(',', ' '));
          } else {
            query.sort({ createdAt: -1 });
          }

          if (cursor) {
            query.where('_id').lt(cursor);
          }

          query.limit(limit);
          query.exec(done);
        };

        async.waterfall([compileConditions, executeQuery], function(error, documents) {
          if (error) {
            logger.error('Resource router failed to query for objects', { model: Model.modelName, error: error.message });
            res.sendError();
          } else {
            res.sendDocuments(documents);
          }
        });
      });
    },

    /**
     * Routes POST requests to resource for individual resource objects for app and Model
     * @param {Object} app - Express app
     * @param {Object} Model - Mongoose model
     */
    routeModelPatchObjectResource: function(app, Model) {
      this.routeModelResource(app, Model, 'patch', '/' + _.kebabCase(Model.modelType()) + '/:id', (req, res) => {
        var validate = (done) => {
          this.validateQueryData(req, req.body.data, Model, 'patch', done);
        };

        var getConditions = (done) => {
          this.compiledQueryConditions(req, { _id: req.params.id }, Model, 'patch', done);
        };

        var findOneAndUpdate = (conditions, done) => {
          Model.findOneAndUpdate(conditions, req.body.data.attributes, { new: true }, done);
        };

        var addRelationships = (document, done) => {
          if (!document) {
            return done(new Error('No document found with ID'));
          }

          if (!req.body.data.relationships) {
            return done(undefined, document);
          }

          this.saveRelationshipsToDocument(document, this.normalizeRelationships(req.body.data.relationships), function(error) {
            done(error, document);
          });
        };

        var saveDocument = (document, done) => {
          document.save((error) => {
            done(error, document);
          });
        };

        var executePostRoutine = (document, done) => {
          if (Model.jsonapi.patch && Model.jsonapi.patch.post) {
            Model.jsonapi.patch.post(req, res, document, function(error, req, res, document) {
              done(error, document);
            });
          } else {
            done(undefined, document);
          }
        };

        async.waterfall([
          validate,
          getConditions,
          findOneAndUpdate,
          addRelationships,
          saveDocument,
          executePostRoutine
        ], function(error, document) {
          if (error) {
            if (error.errors) {
              return res.sendError(error, 400);
            }

            if (error.message === 'No document found with ID') {
              res.sendNotFound();
            } else {
              res.sendError(error, 500);
            }
          } else {
            res.sendDocument(document, 200);
          }
        });
      });
    },

    /**
     * Routes POST requests to resource for individual resource objects for app and Model
     * @param {Object} app - Express app
     * @param {Object} Model - Mongoose model
     */
    routeModelPostObjectResource: function(app, Model) {
      this.routeModelResource(app, Model, 'post', '/'+ _.kebabCase(Model.modelType()), (req, res) => {
        /**
         * Validates all available attributes (TODO: and relationships)
         */
        var validate = (done) => {
          debug('POST validate');
          this.validateQueryData(req, req.body.data, Model, 'post', done);
        };

        /**
         * Creates the document with all available attributes
         */
        var createDocument = (done) => {
          debug('POST createDocument');

          try {
            var document = new Model(req.body.data.attributes);
          } catch (error) {
            return done(error);
          }

          done(undefined, document);
        };

        /**
         * Adds all available relationships to document
         */
        var addRelationships = (document, done) => {
          if (!req.body.data.relationships) {
            return done(undefined, document);
          }

          this.saveRelationshipsToDocument(document, this.normalizeRelationships(req.body.data.relationships), function(error) {
            done(error, document);
          });
        };

        /**
         * Saves the document
         */
        var saveDocument = (document, done) => {
          document.save((error) => {
            done(error, document);
          });
        };

        /**
         * Reloads the document to ensure all autopopulate references are populated
         */
        var reloadDocument = (document, done) => {
          Model.findById(document.id, (error, document) => {
            done(error, document);
          });
        };

        /**
         * Executes any available post-POST routine available for Model
         */
        var executePostRoutine = (document, done) => {
          if (Model.jsonapi.post && Model.jsonapi.post.post) {
            Model.jsonapi.post.post(req, res, document, function(error) {
              done(error, document);
            });
          } else {
            done(undefined, document);
          }
        };

        async.waterfall([
          validate,
          createDocument,
          addRelationships,
          saveDocument,
          reloadDocument,
          executePostRoutine
        ], function(error, document) {
          if (error) {
            if (error.errors) {
              return res.sendError(error, 400);
            }

            return res.sendError(error);
          }

          res.sendDocument(document, 201);
        });
      });
    },

    /**
     * Routes requests to resource callback for app, model, method and path
     * @param {Object} app - Express app
     * @param {model} model - Mongoose model
     * @param {string} method - HTTP method (lowercase, e.g "get")
     * @param {string} path - Path to resource
     * @param {function} done - Express route callback expecting req and res as parameters
     */
    routeModelResource: function(app, model, method, path, done) {
      if (!model.jsonapi || !model.jsonapi[method]) { return; }

      var validateRequestBody = false;

      if (['patch', 'post'].indexOf(method) !== -1) {
        validateRequestBody = this.validateRequestBody(model);
      }

      var middleware = {
        requireAuthentication: (['public'].indexOf(this.allowed(model, method)) === -1),
        requireAdminAuthentication: (['public', 'user'].indexOf(this.allowed(model, method)) === -1),
        validateRequestBody: validateRequestBody
      };

      this.routeResource(app, method, path, middleware, done);
    },

    /**
     * Establish middleware that generates routes conformant to the JSON API specification for app and Mongoose models
     * @param {Object} app - Express app
     */
    routeModelResources: function() {
      /**
       * Negotiate the Content-Type and Accept request headers
       * @see {@link http://jsonapi.org/format/#content-negotiation-servers}
       */
      app.use(function(req, res, next) {
        var isModifiedContentType = function(contentType) {
          return (/application\/vnd\.api\+json/.test(contentType) && contentType !== 'application/vnd.api+json');
        };

        if (req.get('Content-Type') && isModifiedContentType(req.get('Content-Type'))) {
          res.sendStatus(415);
          return;
        }

        if (req.get('Accept')) {
          var badAccept = false;
          req.get('Accept').split(';').forEach(function(accept) {
            badAccept = (isModifiedContentType(accept));
          });

          if (badAccept) {
            res.sendStatus(406);
            return;
          }
        }

        next();
      });

      app.use(function(req, res, next) {
        res.set('Content-Type', 'application/vnd.api+json');

        /**
         * Returns JSON API resource object representing provided Mongoose document
         * Document properties filtered out per model settings
         * @param {Object} document - Mongoose document
         * @returns {Object} object - JSON API resource object
         */
        res.resourceObjectFromDocument = function(document) {
          if (!document) {
            throw new Error('No document provided');
          }

          var Model = models[document.modelId()];

          var attributes = document.toObject();
          delete attributes.id;

          var relationships = {};

          Object.keys(attributes).forEach(function(key) {
            var addRelationship = function(property) {
              if (property && property.id && property.modelType) {
                if (!relationships[key] || !relationships[key].data) {
                  if (Array.isArray(document[key])) {
                    relationships[key] = { data: [] };
                  } else {
                    relationships[key] = { data: {} };
                  }
                }

                var relationship = {
                  id: property.id,
                  type: property.modelType()
                };

                if (Array.isArray(document[key])) {
                  relationships[key].data.push(relationship);
                } else {
                  relationships[key].data = relationship;
                }

                delete attributes[key];
              }
            };

            if (Array.isArray(document[key])) {
              if (document[key].length < 1) {
                delete attributes[key];
              } else {
                document[key].forEach(addRelationship);
              }
            } else {
              addRelationship(document[key]);
            }
          });

          var attributes = _.mapKeys(attributes, (value, key) => {
            return _.kebabCase(key);
          });

          if (Model.jsonapi.filterProperties) {
            Model.jsonapi.filterProperties.forEach((name) => {
              delete attributes[name];
            });
          }

          relationships = _.mapKeys(relationships, (value, key) => {
            return _.kebabCase(key);
          });

          return {
            id: document.id,
            type: document.modelType(),
            attributes: attributes,
            relationships: Object.getOwnPropertyNames(relationships).length > 0 ? relationships : undefined
          };
        };

        /**
         * Returns JSON API resource identifier object representing provided Mongoose document
         * @param {Object} document - Mongoose document
         * @returns {Object} object - JSON API relationship object
         */
        res.resourceIdentifierObjectFromDocument = function(document) {
          if (!document) {
            throw new Error('No document provided');
          }

          return {
            id: document.id,
            type: document.modelType()
          };
        };

        /**
         * Add document as relationship to JSON API resource object
         * @param {Object} object - JSON API resource object
         * @param {Object} document - Mongoose document
         * @param {string} name - Name of relationship (e.g. "author")
         * @param {string} [type=to-many] - Type of relationship (either "to-many" or "to-one")
         */
        res.addRelationshipToResourceObject = function(object, document, name, type) {
          validateParams([{
            name: 'object', variable: object, required: true, requiredType: 'object'
          }, {
            name: 'document', variable: document, required: true, requiredType: 'object'
          }, {
            name: 'name', variable: name, required: true, requiredType: 'string'
          }, {
            name: 'type', variable: type, requiredType: 'string'
          }]);

          name = _.kebabCase(name);

          if (!object.relationships) {
            object.relationships = {};
          }

          if (!object.relationships[name]) {
            object.relationships[name] = {};
          }

          type = type ? type : 'to-many';

          if (type === 'to-many') {
            if (!object.relationships[name].data) {
              object.relationships[name].data = [];
            }

            object.relationships[name].data.push(res.resourceIdentifierObjectFromDocument(document));
          } else if (type === 'to-one') {
            object.relationships[name].data = this.resourceIdentifierObjectFromDocument(document);
          } else {
            throw new Error('Type parameter is not valid');
          }
        };

        /**
         * Sends response document with principal data, included resources or errors
         * @param {Object} data – Principal data (optional)
         * @param {Object} included – Included resources (optional)
         * @param {Object} errors - Errors (optional)
         * @param {number} [status=200] - HTTP status code
         */
        res.sendResponseDocument = function(data, included, errors, status) {
          var doc = {};

          if (data) {
            doc['data'] = data;

            if (included && (!Array.isArray(included) || included.length > 0)) {
              doc['included'] = included;
            }
          } else if (errors) {
            doc['errors'] = errors;
          }

          doc['jsonapi'] = {
            version: '1.0'
          };

          if (!status) {
            status = 200;
          }

          this.status(status).json(doc);
        };

        /**
         * Sends response document with principal data and included resources
         * @param {Object} data – Principal data
         * @param {Object} included – Included resources (optional)
         */
        res.sendData = function(data, included) {
          if (!data) {
            throw new Error('No data parameter provided');
          }

          this.sendResponseDocument(data, included);
        };

        /**
         * Sends response document with model document
         * @param {Object} document - Model document
         * @param {number} status - HTTP status code
         */
        res.sendDocument = function(document, status) {
          res.sendResponseDocument(this.resourceObjectFromDocument(document), null, null, status);
        };

        /**
         * Sends response document with model documents
         * @param {Object} documents - Array of model documents
         */
        res.sendDocuments = function(documents) {
          var objects = documents.map((document) => {
            return this.resourceObjectFromDocument(document);
          });

          res.sendResponseDocument(objects);
        };

        /**
         * Sends response document with 404 status code
         */
        res.sendNotFound = function() {
          res.sendResponseDocument(null, null, null, 404);
        };

        /**
         * Sends response document with error and status code
         * @param {Error} error - Error object (optional) with optional errors property
         * @param {number} [status=500] - HTTP status code
         */
        res.sendError = function(error, status) {
          if (error) {
            var errors = error.errors;

            if (!errors) {
              errors = new Array(error);
            }

            // Convert object of errors to array if needed
            if(typeof errors === 'object' && !Array.isArray(errors)) {
              errors = Object.keys(errors).map(function(key) {
                return errors[key];
              });
            }

            errors = errors.map(function(error) {
              return {
                title: error.message
              };
            });
          }

          if (!status) {
            status = 500;
          }

          this.sendResponseDocument(null, null, errors, status);
        };

        next();
      });

      /**
       * Establish body-parser middleware with JSON API error handling
       */
      app.use(function(req, res, next) {
        var json = bodyParser.json({ type: ['application/vnd.api+json', 'application/json'] });

        json(req, res, function(error) {
          if (error) {
            res.sendError(error, 400);
          } else {
            next();
          }
        });
      });

      app.get('/', function(req, res) {
        res.sendResponseDocument();
      });

      // Route requests for each model with Mongoose compatability and jsonapi configuration
      Object.keys(models).forEach((key) => {
        var model = models[key];
        
        if (model.modelName && model.jsonapi) {
          this.routeModelGetObjectsResource(app, model);
          this.routeModelGetObjectResource(app, model);
          this.routeModelPostObjectResource(app, model);
          this.routeModelPatchObjectResource(app, model);
          this.routeModelDeleteObjectResource(app, model);
        }
      });
    },

    /**
     * Routes requests to resource callback for app, method, path and middleware
     * @param {Object} app - Express app
     * @param {string} method - HTTP method (lowercase, e.g "get")
     * @param {string} path - Path to resource
     * @param {Object} middleware - Dictionary of middleware boolean or function values to use for route
     * @param {function} done - Express route callback expecting req and res as parameters
     */
    routeResource: function(app, method, path, middleware, done) {
      var requireAuthentication = (req, res, next) => {
        if (middleware && middleware.requireAuthentication) {
          app.requireAuthentication(req, res, next);
        } else {
          next();
        }
      };

      var requireAdminAuthentication = (req, res, next) => {
        if (middleware && middleware.requireAdminAuthentication) {
          app.requireAdminAuthentication(req, res, next);
        } else {
          next();
        }
      };

      var validateRequestUrl = (req, res, next) => {
        if (!middleware || middleware.validateRequestUrl !== false) {
          this.validateRequestUrl(req, res, next);
        } else {
          next();
        }
      };

      var validateRequestBody = (req, res, next) => {
        if (middleware && middleware.validateRequestBody && typeof middleware.validateRequestBody === 'function') {
          middleware.validateRequestBody(req, res, next);
        } else if (middleware && middleware.validateRequestBody) {
          this.validateRequestBody()(req, res, next);
        } else {
          next();
        }
      };

      app[method](path, requireAuthentication, requireAdminAuthentication, validateRequestUrl, validateRequestBody, done);
    },

    /**
     * Saves all provided relationships to document
     * @param {Object} document - Mongoose document
     * @param {Object} document - Key-value object of relationships
     * @param {callback} done
     */
    saveRelationshipsToDocument: function(document, relationships, done) {
      var validate = function(done) {
        validateParams([{
          name: 'document', variable: document, required: true, requiredType: ['object', 'constructor']
        }, {
          name: 'relationships', variable: relationships, required: true, requiredType: ['object']
        }, {
          name: 'done', variable: done, required: true, requiredType: ['function']
        }], done);
      };

      var saveRelationshipsToDocument = function(done) {
        var Model = models[document.modelId()];
        
        async.forEachOf(relationships, function(relationship, relationshipName, done) {
          var validateRelationship = function(done) {
            var errors = [];

            if (!Model.schema.tree[relationshipName]) {
              errors.push(new Error(`Relationship name "${relationshipName}" is not valid`));
            } else if (Array.isArray(Model.schema.tree[relationshipName]) && !Model.schema.tree[relationshipName][0].ref) {
              errors.push(new Error(`Relationship name "${relationshipName}"is not valid`));
            } else if (!Array.isArray(Model.schema.tree[relationshipName]) && (typeof Model.schema.tree[relationshipName] !== 'object' || !Model.schema.tree[relationshipName].ref)) {
              errors.push(new Error(`Relationship name "${relationshipName}"is not valid`));
            }

            if (Array.isArray(Model.schema.tree[relationshipName]) && !Array.isArray(relationship.data)) {
              errors.push(new Error(`Relationship data should not be an array given name "${relationshipName}"`));
            }

            if (!Array.isArray(relationship.data) && Array.isArray(Model.schema.tree[relationshipName])) {
              errors.push(new Error(`Relationship data should be an array given name "${relationshipName}"`));
            }

            if (errors.length > 0) {
              var error = new Error(`Relationship "${relationshipName}" is not properly formatted`);
              error.errors = errors;
            }

            done(error);
          };

          var addRelationshipToDocument = function(done) {
            var validateAndAddResourceIdentifierObjectRelationship = function(resourceObject, done) {
              var validateResourceIdentifierObject = function(done) {
                var errors = [];

                if (!resourceObject.id) {
                  errors.push(new Error(`Relationship resource identifier object for "${relationshipName}" does not have id property`));
                }

                if (!resourceObject.type) {
                  errors.push(new Error(`Relationship resource identifier object for "${relationshipName}" does not have type property`));
                }

                var ref;

                if (Array.isArray(Model.schema.tree[relationshipName])) {
                  ref = Model.schema.tree[relationshipName][0].ref;
                } else {
                  ref = Model.schema.tree[relationshipName].ref;
                }

                if (models[_.lowerFirst(ref)].modelType() !== resourceObject.type) {
                  errors.push(new Error(`Relationship resource identifier object type "${resourceObject.type}" is not valid`));
                }

                if (!ObjectId.isValid(resourceObject.id)) {
                  errors.push(new Error(`Relationship resource identifier object ID "${resourceObject.id}" is not valid`));
                }

                if (errors.length > 0) {
                  var error = new Error('Relationship resource identifier object is not properly formatted');
                  error.errors = errors;
                }

                done(error);
              };

              var addResourceIdentifierToDocument = function(done) {
                if (Array.isArray(Model.schema.tree[relationshipName]) && document[relationshipName].indexOf(resourceObject.id) === -1) {
                  document[relationshipName].push(resourceObject.id);
                } else if (!Array.isArray(Model.schema.tree[relationshipName])) {
                  document[relationshipName] = resourceObject.id;
                }

                done();
              };

              async.series([validateResourceIdentifierObject, addResourceIdentifierToDocument], function(error) {
                done(error, document);
              });
            };

            if (Array.isArray(relationship.data)) {
              async.forEach(relationship.data, validateAndAddResourceIdentifierObjectRelationship, done);
            } else {
              validateAndAddResourceIdentifierObjectRelationship(relationship.data, done);
            }
          };

          async.series([validateRelationship, addRelationshipToDocument], done);
        }, done);
      };

      async.series([validate, saveRelationshipsToDocument], function(error) {
        done(error, document);
      });
    },

    /**
     * Callbacks error with array of validation errors if query data do not conform to model's value restrictions given requester's status (e.g. public, user, admin)
     * @param {Object} req - Express request object
     * @param {Object} data - Query data
     * @param {Object} model - Mongoose model (optional)
     * @param {string} method - HTTP method (optional)
     * @param {function} done - Error-first callback function expecting no other parameters
     */
    validateQueryData: function(req, data, model, method, done) {
      var getConditions = (done) => {
        this.modelQueryConditions(req, model, method, done);
      };

      var validateQueryData = (conditions, done) => {
        var errors = [];

        Object.keys(conditions).forEach(function(key) {
          var isEqualObjectId;
          
          try {
            isEqualObjectId = ObjectId(_.get(data, `relationships.${key}.data.id`)).equals(conditions[key]);
          } catch (error) {
            isEqualObjectId = false;
          }
          
          if (conditions[key] !== data.attributes[key] && !isEqualObjectId) {
            errors.push(new Error(`Value for attribute ${key} invalid`));
          }
        });

        if (errors.length > 0) {
          var error = new Error('Query data invalid');
          error.errors = errors;

          return done(error);
        }

        done();
      };

      async.waterfall([getConditions, validateQueryData], done);
    },

    /**
     * Returns Express route middleware that validates request body against JSON API specification and URL for optional model
     * @param {Object} model - Mongoose model (optional)
     */
    validateRequestBody: function(model) {
      return function(req, res, next) {
        var errors = [];

        debug('req.body %o', req.body);

        if (typeof req.body.data === 'undefined') {
          errors.push(new Error('Data value not provided top-level in body of request'));
        } else {
          if (typeof req.body.data.attributes === 'undefined') {
            errors.push(new Error('Attributes value not provided within data value of request'));
          }

          if (typeof req.body.data.type === 'undefined') {
            errors.push(new Error('Type value not provided within data value of request'));
          } else if (model && req.body.data.type !== model.modelType()) {
            errors.push(new Error('Type value provided within data value of request does not match type indicated by URL'));
          }

          if (req.params.id && !req.body.data.id) {
            errors.push(new Error('ID value not provided within data value of request'));
          } else if (req.params.id && req.body.data.id !== req.params.id) {
            errors.push(new Error('ID value provided within data value of request does not match ID indicated by URL'));
          }
        }

        if (errors.length > 0) {
          var error = new Error('Failed to validate request body');
          error.errors = errors;
          res.sendError(error, 400);
        } else {
          next();
        }
      };
    },

    /**
     * Validates request URL against Mongoose needs
     */
    validateRequestUrl: function(req, res, next) {
      if (req.params.id && !req.params.id.match(/^[0-9a-fA-F]{24}$/)) {
        res.sendError(new Error('Resource ID indicated with invalid format in URL'), 400);
      } else {
        next();
      }
    }
  };
};