Source: index.js

var _ = require('lodash'),
  keystone = require('keystone');

/**
  <p>Example usage</p>
  <pre>
  var keystone = require('keystone'),
    Types = keystone.Field.Types,
    keystoneRest = require('keystone-rest');

  var User = new keystone.List('User');

  User.add({
    name: { type: Types.Name, required: true, index: true },
    password: { type: Types.Password, initial: true, required: false, restSelected: false },
    token: { type: String, restEditable: false }
  });

  // Add user api endpoints
  keystoneRest.addRoutes(User, 'list show create update delete', {
    list: [listMiddleware],
    show: [showMiddleware],
    create: [createMiddleware],
    update: [updateMiddleware],
    delete: [deleteMiddleware]
  }, 'posts');

  User.register();

  // Make sure keystone is initialized and started before
  // calling registerRoutes
  keystone.init(config);
  keystone.start();

  // Add routes to app
  keystoneRest.registerRoutes(keystone.app);
  </pre>
 */


/**
 * @constructor
 */
function KeystoneRest() {
  var self = this;

  // Mongoose instance attached to keystone object.
  // Assigned in addRoutes
  var mongoose;

  /**
   * Array containing routes and handlers
   * @type {Array}
   */

  self.routes = [];


  /**
   * Send a 404 response
   * @param  {Object} res     Express response
   * @param  {String} message Message
   */
  var _send404 = function (res, message) {
    res.status(404);
    res.json({
      status: 'missing',
      message: message
    });
  };


  /**
   * Send an error response
   * @param {Object} err Error response object
   * @param {Object} res Express response
   */

  var _sendError = function (err, req, res, next) {
    /*jslint unparam: true */
    next(err);
  };


  /**
   * Convert fields that are relationships to _ids
   * @param {Object} model instance of mongoose model
   */

  var _flattenRelationships = function (model, body) {
    _.each(body, function (field, key) {
      var schemaField = model.schema.paths[key];

      // return if value is a string
      if (typeof field === 'string' || !schemaField) { return; }

      if (schemaField.options.ref) {
        body[key] = field._id;
      }

      if (_.isArray(schemaField.options.type)) {
        if (schemaField.options.type[0].ref) {
          _.each(field, function (value, i) {
            if (typeof value === 'string' || !value) { return; }
            body[key][i] = value._id;
          });
        }
      }
    });
  };


  /**
   * Get list of selected fields based on options in schema
   * @param {Schema} schema Mongoose schema
   */

  var _getSelected = function (schema) {
    var selected = [];

    _.each(schema.paths, function (path) {
      if (path.options.restSelected !== false) {
        selected.push(path.path);
      }
    });

    return selected.join(' ');
  };


  /**
   * Get Uneditable
   * @param {Schema} schema Mongoose schema
   */

  var _getUneditable = function (schema) {
    var uneditable = [];

    _.each(schema.paths, function (path) {
      if (path.options.restEditable === false) { uneditable.push(path.path); return; }
      if (path.options.type.constructor.name === 'Array') { if (path.options.type[0].restEditable === false) { uneditable.push(path.path); } }
    });

    return uneditable;
  };


  /**
   * Get name of reference model
   * @param {Model}  Model Mongoose model
   * @param {String} path Ref path to get name from
   */
  var _getRefName = function (Model, path) {
    var options = Model.schema.paths[path].options;

    // One to one relationship
    if (options.ref) {
      return options.ref;
    }

    // One to many relationsihp
    return options.type[0].ref;
  };


  /**
   * Add get route
   * @param {Model}  model      Mongoose Model
   * @param {Mixed}  middleware Express middleware to execute before route handler
   * @param {String} selected   String passed to mongoose "select" method
   */

  var _addList = function (Model, middleware, selected, relationships) {

    // Get a list of items
    self.routes.push({
      method: 'get',
      middleware: middleware,
      route: '/api/' + Model.collection.name.toLowerCase(),
      handler: function (req, res, next) {
        var populated = req.query.populate ? req.query.populate.split(',') : [],
          criteria = _.omit(req.query, ['populate', '_', 'limit', 'skip', 'sort', 'select']),
          querySelect;

        if (req.query.select) {
          querySelect = req.query.select.split(',');
          querySelect = querySelect.filter(function (field) {
            return (selected.indexOf(field) > -1);
          }).join(' ');
        }

        Model.find().count(function (err, count) {
          if (err) { return _sendError(err, req, res, next); }

          var query = Model.find(criteria).skip(req.query.skip)
            .limit(req.query.limit)
            .sort(req.query.sort)
            .select(querySelect || selected);

          populated.forEach(function (path) {
            query.populate({
              path: path,
              select: _getSelected(mongoose.model(_getRefName(Model, path)).schema)
            });
          });

          query.exec(function (err, response) {
            if (err) { return _sendError(err, req, res, next); }

            // Make total total accessible via response headers
            res.setHeader('total', count);
            res.json(response);
          });
        });
      }
    });


    // Get a list of relationships
    if (relationships) {

      _.each(relationships, function (relationship) {
        self.routes.push({
          method: 'get',
          middleware: [],
          route: '/api/' + Model.collection.name.toLowerCase() + '/:id/' + relationship,
          handler: function (req, res, next) {
            Model.findById(req.params.id).exec(function (err, result) {
              var total,
                criteria = _.omit(req.query, ['populate', '_', 'limit', 'skip', 'sort', 'select']),
                ref,
                query,
                querySelect,
                refSelected,
                sortedResults = [];

              if (err && err.type !== 'ObjectId') { return _sendError(err, req, res, next); }
              if (!result) { return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id); }

              total = result[relationship].length;
              ref = Model.schema.paths[relationship].caster.options.ref;

              refSelected = _getSelected(mongoose.model(ref).schema);

              query = mongoose.model(ref)
                .find(criteria)
                .in('_id', result[relationship])
                .limit(req.query.limit)
                .skip(req.query.skip)
                .sort(req.query.sort);

              if (req.query.select) {
                querySelect = req.query.select.split(',');
                querySelect = querySelect.filter(function (field) {
                  return (refSelected.indexOf(field) > -1);
                }).join(' ');
                query.select(querySelect);
              }

              if (req.query.populate && typeof req.query.populate === 'string') {
                query.populate(req.query.populate);
              }

              query.exec(function (err, response) {
                if (err) { return _sendError(err, req, res, next); }

                // Put relationship results into same order
                // that they appear in document
                if (!req.query.sort) {
                  result[relationship].forEach(function (_id, i) {
                    sortedResults[i] = _.findWhere(response, { _id: _id });
                  });
                  response = sortedResults;
                }

                // Make total total accessible via response headers
                res.setHeader('total', total);
                res.json(response);
              });
            });
          }
        });
      });
    }
  };


  /**
   * Add list route
   * @param {Model}  model      Mongoose Model
   * @param {Mixed}  middleware Express middleware to execute before route handler
   * @param {String} selected   String passed to mongoose "select" method
   */

  var _addShow = function (Model, middleware, selected, findBy) {
    var collectionName = Model.collection.name.toLowerCase();
    var paramName = Model.modelName.toLowerCase();

    // Get one item
    self.routes.push({
      method: 'get',
      middleware: middleware,
      route: '/api/' + collectionName + '/:' + paramName,
      handler: function (req, res, next) {
        var populated = req.query.populate ? req.query.populate.split(',') : [];
        var criteria = {};
        var querySelect;

        if (req.query.select) {
          querySelect = req.query.select.split(',');
          querySelect = querySelect.filter(function (field) {
            return (selected.indexOf(field) > -1);
          }).join(' ');
        }

        criteria[findBy] = req.params[paramName];

        var query = Model.findOne(criteria)
          .select(querySelect || selected);

        populated.forEach(function (path) {
          query.populate({
            path: path,
            select: _getSelected(mongoose.model(_getRefName(Model, path)).schema)
          });
        });

        query.exec(function (err, result) {
          if (err && err.type !== 'ObjectId') { return _sendError(err, req, res, next); }
          if (!result) { return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id); }
          res.json(result);
        });
      }
    });
  };


  /**
   * Add post route
   * @param {Model}  Model      Mongoose Model
   * @param {Mixed}  middleware Express middleware to execute before route handler
   * @param {String} selected   String passed to mongoose "select" method
   */

  var _addCreate = function (Model, middleware, selected) {

    // Create a new item
    self.routes.push({
      method: 'post',
      middleware: middleware,
      route: '/api/' + Model.collection.name.toLowerCase(),
      handler: function (req, res, next) {
        var item;

        _flattenRelationships(Model, req.body);

        item = new Model(req.body);

        item.save(function (err, item) {
          if (err) { return _sendError(err, req, res, next); }

          Model.findById(item._id).select(selected).exec(function (err, item) {
            if (err) { return _sendError(err, req, res, next); }
            res.json(item);
          });
        });
      }
    });
  };


  /**
   * Add put route
   * @param {Model}  Model      Mongoose Model
   * @param {Mixed}  middleware Express middleware to execute before route handler
   * @param {String} selected   String passed to mongoose "select" method
   * @param {Array}  uneditable Array of fields to remove from post
   */

  var _addUpdate = function (Model, middleware, uneditable, selected, findBy) {
    var collectionName = Model.collection.name.toLowerCase();
    var paramName = Model.modelName.toLowerCase();
    var versionKey = Model.schema.options.versionKey;

    // Update an item having a given key
    self.routes.push({
      method: 'put',
      middleware: middleware,
      route: '/api/' + collectionName + '/:' + paramName,
      handler: function (req, res, next) {
        var populated = req.query.populate ? req.query.populate.split(',') : '';
        var criteria = {};
        var querySelect;

        if (req.query.select) {
          querySelect = req.query.select.split(',');
          querySelect = querySelect.filter(function (field) {
            return (selected.indexOf(field) > -1);
          }).join(' ');
        }

        criteria[findBy] = req.params[paramName];

        _flattenRelationships(Model, req.body);
        req.body = _.omit(req.body, uneditable);

        Model.findOne(criteria).exec(function (err, item) {

          /*jslint unparam: true */
          if (err && err.type !== 'ObjectId') { return _sendError(err, req, res, next); }
          if (!item) { return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id); }

          if (req.body[versionKey] < item[versionKey]) { return _sendError(new mongoose.Error.VersionError(), req, res, next); }

          _.extend(item, req.body);

          item.save(function (err, item) {
            if (err) { return _sendError(err, req, res, next); }

            Model.findOne(criteria).select(querySelect || selected).populate(populated).exec(function (err, item) {
              if (err) { return _sendError(err, req, res, next); }
              res.json(item);
            });
          });
        });
      }
    });
  };


  /**
   * Add delete route
   * @param {Model} model      Mongoose Model
   * @param {Mixed} middleware Express middleware to execute before route handler
   */

  var _addDelete = function (Model, middleware, findBy) {
    var collectionName = Model.collection.name.toLowerCase();
    var paramName = Model.modelName.toLowerCase();

    // Delete an item having a given id
    self.routes.push({
      method: 'delete',
      middleware: middleware,
      route: '/api/' + collectionName + '/:' + paramName,
      handler: function (req, res, next) {
        var criteria = {};

        criteria[findBy] = req.params[paramName];

        // First find so middleware hooks (pre,post) will execute
        Model.findOne(criteria, function (err, item) {
          if (err && err.type !== 'ObjectId') { return _sendError(err, req, res, next); }
          if (!item) { return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id); }

          item.remove(function (err) {
            if (err) { return _sendError(err, req, res, next); }
            res.json({
              message: 'Successfully deleted ' + collectionName
            });
          });
        });
      }
    });
  };


  /**
   * Add routes
   * @param {Object} keystoneList  Instance of KeystoneList
   * @param {String} methods       Methods to expose('list show create update delete')
   * @param {Object} middleware    Map containing middleware to execute for each action ({ list: [middleware] })
   * @param {String} relationships Space separated list of relationships to build routes for
   */

  this.addRoutes = function (keystoneList, methods, middleware, relationships) {
    // Get reference to mongoose for internal use
    mongoose = keystone.mongoose;

    var findBy;
    var Model = keystoneList.model;

    if (!Model instanceof mongoose.model) { throw new Error('keystoneList is required'); }
    if (!methods) { throw new Error('Methods are required'); }
    if (!mongoose) { throw new Error('Keystone must be initialized before attempting to add routes'); }

    var selected = _getSelected(Model.schema),
      uneditable = _getUneditable(Model.schema),
      listMiddleware,
      showMiddleware,
      createMiddleware,
      updateMiddleware,
      deleteMiddleware;

    methods = methods.split(' ');

    // Use autoKey to find doc if it exists
    if (keystoneList.options.autokey) {
      findBy = keystoneList.options.autokey.path;
    } else {
      findBy = '_id';
    }

    // Set up default middleware
    middleware = middleware || {};
    listMiddleware = middleware.list || [];
    showMiddleware = middleware.show || [];
    createMiddleware = middleware.create || [];
    updateMiddleware = middleware.update || [];
    deleteMiddleware = middleware.delete || [];

    relationships = relationships ? relationships.split(' ') : [];

    if (methods.indexOf('list') !== -1) { _addList(Model, listMiddleware, selected, relationships); }
    if (methods.indexOf('show') !== -1) { _addShow(Model, showMiddleware, selected, findBy); }
    if (methods.indexOf('create') !== -1) { _addCreate(Model, createMiddleware, selected); }
    if (methods.indexOf('update') !== -1) { _addUpdate(Model, updateMiddleware, uneditable, selected, findBy); }
    if (methods.indexOf('delete') !== -1) { _addDelete(Model, deleteMiddleware, findBy); }
  };


  /**
   * Register routes
   * @param  {Object} app Express app
   */

  this.registerRoutes = function (app) {
    _.each(self.routes, function (route) {
      app[route.method](route.route, route.middleware, route.handler);
    });
  };
}

/*
** Exports
*/

module.exports = new KeystoneRest();