'use strict';
var _ = require('lodash');
/**
* Options
*
* - Model
* + rest {Boolean}
* + restOptions {String} 'list show create update delete'
*
* - Methods
* + restHooks {Object}
*
* {
list: [listMiddleware],
show: [showMiddleware],
create: [createMiddleware],
update: [updateMiddleware],
delete: [deleteMiddleware]
}
*
* - Fields
* + restSelected {Boolean}
* + restEditable {Boolean}
*/
/**
* API Blueprint Documentation templates
* Variables
* - name
* - root
* - endpoint
* - attributes
*
* + Ignored
* - id
*/
var api_blueprint = {
new_line: '\n',
tab: ' ',
api_doc_templates: {
model: '# Endpoint for {name} [{root}{endpoint}]\nThis endpoint will provide all the required methods available for {name}',
list: '## List all {name} [GET {root}{endpoint}]\nRetrieves the list of {name}\n\n+ Response 200 (application/json)',
show: '## Retrieve {name} [GET {root}{endpoint}/{id}]\nRetrieves item with the id\n\n+ Response 200 (application/json)',
create: '## Create a {name} [POST {root}{endpoint}]\n\n+ Attributes\n{attributes}\n\n+ Response 200 (application/json)',
update: '## Updates a {name} [PUT {root}{endpoint}]\n\n+ Attributes\n{attributes}\n\n+ Response 200 (application/json)',
delete: '## Deletes an item from {name} [DELETE {root}{endpoint}/{id}]\nDelete a {name}. **Warning:** This action **permanently** removes the {name} from the database.\n\n+ Response 200 (application/json)',
},
/**
* Gets a method type and the vars and created the documentation Text
*
* @param {String} type Method type
* @param {Object} vars Variables to be merged
* @returns {String} Template output
*/
_docTemplate: function (type, vars) {
var tmp = api_blueprint.api_doc_templates[type];
_.forEach(vars, function (val, key) {
tmp = tmp.replace(new RegExp('{' + key + '}', 'g'), val);
});
return tmp;
},
convertType: function (type) {
type = type.toLowerCase();
if (type == 'objectid') {
type = 'object';
}
return type;
},
convertDefault: function (value) {
if (typeof (value) == 'string' && value == '') {
return "''";
}
if (!value) {
return "''";
}
return value;
}
};
/**
* @constructor
*/
function KeystoneRest() {
var self = this;
/**
* Root of the API
*/
var apiRoot = '/api/';
var api_doc = {};
// Mongoose instance attached to keystone object.
// Assigned in addRoutes
var mongoose,
keystone;
/**
* 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 || _.isEmpty(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 _getSelectedFieldsArray = function (schema) {
var selected = [];
_.each(schema.paths, function (path) {
if (path.options.restSelected !== false) {
selected.push(path);
}
});
return selected;
};
/**
* Get list of selected fields based on options in schema
*
* @param {Schema} schema Mongoose schema
*/
var _getSelectedArray = function (schema) {
/*var selected = [];
_.each(schema.paths, function (path) {
if (path.options.restSelected !== false) {
selected.push(path.path);
}
});*/
return _.pluck(_getSelectedFieldsArray(schema), 'path');
};
/**
* Get list of selected fields based on options in schema
* @param {Schema} schema Mongoose schema
*/
var _getSelected = function (schema) {
return _getSelectedArray(schema).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;
};
var _registerList = function (md) {
// Check if Rest must be enabled
try {
if (md && md.options.rest) {
// Register the model
addRoutes(md, md.options.restOptions, md.restHooks, md.model.collection.name);
} else {
console.info('Rest is not enabled for ' + md.model.collection.name);
}
} catch (e) {
console.info('Error registering List. Please verify')
}
};
/**
* Register the models that has the Rest Option enabled
*
* @param {Object} app Keystone App
*/
var _registerRestModels = function (app) {
// Get the models
var keys = Object.keys(app.mongoose.models);
for (var i = 0; i < keys.length; i++) {
// Get the Keyston List
var md = app.list(keys[i]);
_registerList(md);
}
};
/**
* Creates the documentation for an endpoint
*
* @param {String} method The method to be created
* @param {Object} Model The Model
*/
var _createDocumentation = function (method, Model) {
var collectionName = Model.collection.name;
// Defaults
var vars = {
name: collectionName,
root: apiRoot,
endpoint: collectionName.toLowerCase()
};
// create / update
if (method == 'create' || method == 'update') {
var selecteds = _getSelectedFieldsArray(Model.schema);
var attributes = [];
_.forEach(selecteds, function (selected) {
var tmp = api_blueprint.tab + '+ ' + selected.path;
if (selected.instance != undefined) {
tmp += ' (' + api_blueprint.convertType(selected.instance) + ')';
}
if (_.has(selected.options, 'default') && typeof (selected.options.default) != 'Function') {
tmp += api_blueprint.new_line + api_blueprint.tab + api_blueprint.tab + '+ Default: ' + api_blueprint.convertDefault(selected.options.default);
}
if (_.has(selected.options, 'ref')) {
tmp += api_blueprint.new_line + api_blueprint.tab + api_blueprint.tab + '+ Reference: ' + selected.options.ref;
}
attributes.push(tmp);
});
vars['attributes'] = attributes.join(api_blueprint.new_line);
}
api_doc[collectionName.toLowerCase()][method] = api_blueprint._docTemplate(method, vars);
};
/**
* 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) {
// Create Docs
_createDocumentation('show', Model);
// Get a list of items
self.routes.push({
method: 'get',
middleware: middleware,
route: apiRoot + 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: apiRoot + 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) {
// Create Docs
_createDocumentation('list', Model);
var collectionName = Model.collection.name.toLowerCase();
var paramName = Model.modelName.toLowerCase();
// Get one item
self.routes.push({
method: 'get',
middleware: middleware,
route: apiRoot + 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 Docs
_createDocumentation('create', Model);
// Create a new item
self.routes.push({
method: 'post',
middleware: middleware,
route: apiRoot + Model.collection.name.toLowerCase(),
handler: function (req, res, next) {
var item;
_flattenRelationships(Model, req.body);
var md = new Model();
// Get the UpdateHandler from Keystone and process the Request
md.getUpdateHandler(req).process(req.body, {
flashErrors: false
}, 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) {
// Create Docs
_createDocumentation('update', Model);
var collectionName = Model.collection.name.toLowerCase();
var paramName = Model.modelName.toLowerCase();
var versionKey = Model.schema.options.versionKey;
var 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); // Not sure about extending with UpdateHandler
// Get the UpdateHandler and update
item.getUpdateHandler(req).process(req.body, {
flashErrors: false
}, 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);
});
});
});
};
// Update an item having a given key
self.routes.push({
method: 'put',
middleware: middleware,
route: apiRoot + collectionName + '/:' + paramName,
handler: handler
});
self.routes.push({
method: 'patch',
middleware: middleware,
route: apiRoot + collectionName + '/:' + paramName,
handler: handler
});
};
/**
* Add delete route
* @param {Model} model Mongoose Model
* @param {Mixed} middleware Express middleware to execute before route handler
*/
var _addDelete = function (Model, middleware, findBy) {
// Create Docs
_createDocumentation('delete', Model);
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: apiRoot + 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
*/
var 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 collectionName = Model.collection.name;
if (!_.has(api_doc, collectionName.toLowerCase())) {
api_doc[collectionName.toLowerCase()] = {
model: api_blueprint._docTemplate('model', {
name: collectionName,
root: apiRoot,
endpoint: collectionName.toLowerCase()
})
};
}
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 a Keystone List manually.
*
* @param {Object} list Object of Type Keystone List
*/
this.registerList = function (list) {
_registerList(list);
};
/**
* Creates Rest
*
* @param {Object} app Keystone instance
*/
this.createRest = function (kref, options) {
keystone = kref; // Get the app reference of Keystone
if (options == undefined) {
options = {};
};
if (_.has(options, 'apiRoot') && options.apiRoot != '') {
apiRoot = options.apiRoot;
}
// Get and register the models
_registerRestModels(keystone);
_.each(self.routes, function (route) {
keystone.app[route.method](route.route, route.middleware, route.handler);
});
};
/**
* Returns the API Docs for the API Created
*
* @returns {String} The Blueprint formatted API
*/
this.apiDocs = function () {
var md_doc = [];
_.forEach(api_doc, function (model_doc, key) {
var documentation = [];
_.forEach(model_doc, function (doc, k) {
documentation.push(doc);
});
md_doc.push(documentation.join((api_blueprint.new_line + api_blueprint.new_line)));
});
return md_doc.join((api_blueprint.new_line + api_blueprint.new_line));
};
}
/*
** Exports
*/
module.exports = new KeystoneRest();