var debug = require('debug')('Resource'),
express = require('express'),
filterParser = require('./odata-filter'),
querystring = require('querystring'),
_ = require('lodash');
/**
* <p>Constructs a resource.</p>
*
* <p>Keys</p>
*
* <ul>
* <li>model (object): The instance of the Mongoose model (required).</li>
* <li>rel (string): The absolute path of the new resource (required).</li>
* <li>create (boolean): Specifies if the resource should support create (POST) (default true).</li>
* <li>update (boolean): Specifies if the resource should support update (PUT) (default true).</li>
* <li>delete (boolean): Specifies if the resource should support delete (DELETE) (default true).</li>
* <li>lean (boolean): Whether find[ById] queries should be 'lean' and return pojos (default true). If false then
* resource instances, prior to mapping an object for return, could make use of methods on the instance model.</li>
* <li>populate (string||Array): Specifies a property, or list of properties, to populate into objects (<strong>deprecated</strong> use <code>$expand</code>).</li>
* <li>count (boolean): Specifies if the resource should support counts on find and when traversed to by standard relationships from other types (default: false).</li>
* </ul>
*
* <p>The following keys set defaults for possible query arguments.</p>
* <ul>
* <li>$top (number): The default value for $top if not supplied on the query string (default none).</li>
* <li>$skip (number): The default value for $skip if not supplied on the query string (default none).</li>
* <li>$orderby (string): The default value for $orderby if not supplied on the query string (default none). This value
* must use odata syntax like "foo asc,bar desc,baz" rather than mongo syntax like "foo -bar baz".</li>
* <li>$orderbyPaged (string): The name of an attribute to sort results by when clients are paging, send $top, but have not
* explicitly sent $orderby. (default '_id').</li>
* <li>$select (string): The default value for $select if not supplied on the query string (default none, all properties).
* If a value is supplied then $select on the query string will be ignored to protect against the
* situation where the intent is to hide internal attributes (e.g. '-secret'). Unlike odata the
* syntax here is passed through to mongo so the '-' prefix will be honored.</li>
* <li>$expand (string|Array): Specifies a property or list of properties to populate into objects. This value acts as the
* default value for the <code>$expand</code> URL argument. If the URL argument is supplied it
* over-rides this value. Nested expansion is supported. E.g. <code>_book._author</code> will end up in
* both the <code>_book</code> reference being expanded and its <code>_author</code> reference being expanded.
* The corresponding Mongoose model <code>ObjectId</code> properties <strong>must</strong> have their
* <code>ref</code> properties set properly or expansion cannot work.</li>
* </ul>
*
* @constructor
* @param {Object} definition The resource definition.
*/
var Resource = function(definition) {
var self = this;
this._definition = _.extend({count: false},definition);
if(this._definition.count) {
this._definition.staticLinks = {
'count': function(req,res) { self.count(req,res); }
}
}
};
/**
* Send a response error.
* @todo currently this function unconditionally sends the error with the response.
* this may not be desirable since often exposing an error (e.g. stack trace) poses
* a potential security vulnerability.
*
* @param {Object} res The express response object.
* @param {Number} rc The response status code (default 500).
* @param {String} message The response message.
* @param {Object} err The error object.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the error object.
*/
Resource.sendError = function(res,rc,message,err,next) {
rc = rc||500;
var response = {
status: rc,
message: message,
err: err
};
res.status(rc).send(response);
if(typeof(next) === 'function') {
next(err||response);
}
};
/**
* Parse a $filter populating a mongoose query with its cotents.
*
* @param {Object} A mongoose query.
* @param {String} A $filter value.
*/
Resource.parseFilter = filterParser;
/**
* @return {Object} The resource definition.
*/
Resource.prototype.getDefinition = function() {
return this._definition;
};
/**
* @return {String} The resource relative path.
*/
Resource.prototype.getRel = function() {
return this._definition.rel;
};
/**
* @return {Object} The underlying mongoose model.
*/
Resource.prototype.getModel = function() {
return this._definition.model;
};
/**
* @return {Array} The list of instance link names.
*/
Resource.prototype.getInstanceLinkNames = function() {
var def = this.getDefinition();
return def.instanceLinks ? Object.keys(def.instanceLinks) : [];
};
/**
* @return {Array} The list of static link names.
*/
Resource.prototype.getStaticLinkNames = function() {
var def = this.getDefinition();
return def.staticLinks ? Object.keys(def.staticLinks) : [];
};
/**
* Sends a single object instance response.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Array} objs The array of objects to send.
* @param {Function} [postMapper] Optional Array.map callback that will be called with each raw object instance.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.singleResponse = function(req,res,obj,postMapper,next) {
var response = this.getMapper(postMapper)(obj);
res.send(response);
if(typeof(next) === 'function') {
next(null,response);
}
};
Resource.prototype._listResponse = function(linkGenerator,req,res,objs,postMapper,next) {
var response = {
list: objs.map(this.getMapper(postMapper))
},
qDef = req.$odataQueryDefinition;
response._links = linkGenerator(req,response);
if(qDef && qDef.$top) {
// next,prev links
response._links = response._links||{};
// looks odd but $top could be part of the service definition so
// if its there use it but over-ride if supplied by the client.
var forwardArgs = _.extend({$top: qDef.$top},req.query),
nextArgs = _.extend({},forwardArgs,{$skip:(parseInt(forwardArgs.$skip||0)+parseInt(forwardArgs.$top))})
prevArgs = _.extend({},forwardArgs,{$skip:(parseInt(forwardArgs.$skip||0)-parseInt(forwardArgs.$top))}),
baseUrl = req.originalUrl.replace(/\?.*$/,'');
if(prevArgs.$skip >= 0) {
response._links.prev = baseUrl+'?'+querystring.stringify(prevArgs);
}
// only add the next link if there are exactly the requested number of objects.
// can't be sure if the next page might not be empty.
if(response.list.length === parseInt(qDef.$top)){
response._links.next = baseUrl+'?'+querystring.stringify(nextArgs);
}
}
res.send(response);
if(typeof(next) === 'function') {
next(null,response);
}
};
Resource.prototype._findListResponse = function(req,res,objs,postMapper,next) {
var rel = this.getRel(),
links = this.getStaticLinkNames(),
def = this.getDefinition();
this._listResponse(function(){
if(links.length) {
var lnks = links.reduce(function(map,link){
map[link] = rel+'/'+link;
return map;
},{});
if(def.count && lnks.count) {
var q = _.extend({},(req.query||{}));
delete q.$top;
delete q.$skip;
delete q.$orderby;
if(Object.keys(q).length) {
lnks.count += '?'+querystring.stringify(q);
}
} else if (!def.count) {
delete lnks.count;
}
return lnks;
}
},req,res,objs,postMapper,next);
};
/**
* Sends a list response.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Array} objs The array of objects to send.
* @param {Function} [postMapper] Optional Array.map callback that will be called with each raw object instance.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.listResponse = function(req,res,objs,postMapper,next) {
var rel = this.getRel(),
links = this.getStaticLinkNames();
this._listResponse(function(){
if(links.length) {
return links.reduce(function(map,link){
if(link !== 'count') {
map[link] = rel+'/'+link;
}
return map;
},{});
}
},req,res,objs,postMapper,next);
};
/**
* Sends a list response when a relationship is traversed. This is used for standard relationships to allow the
* static count link to be handled.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Array} objs The array of objects to send.
* @param {Function} [postMapper] Optional Array.map callback that will be called with each raw object instance.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.relListResponse = function(req,res,objs,postMapper,next) {
var rel = this.getRel(),
links = this.getStaticLinkNames(),
def = this.getDefinition()
this._listResponse(function(){
var lnks = links.reduce(function(map,link){
map[link] = rel+'/'+link;
return map;
},{});
if(def.count) {
// replace count
var q = _.extend({},(req.query||{}));
delete q.$top;
delete q.$skip;
delete q.$orderby;
if(Object.keys(q).length) {
lnks.count = req.originalUrl.substring(0,req.originalUrl.indexOf('?'))+'/count?'+querystring.stringify(q);
} else {
q = req.originalUrl.indexOf('?');
lnks.count = (q !== -1 ? req.originalUrl.substring(0,q) : req.originalUrl)+'/count';
}
} else {
delete lnks.count;
}
return lnks;
},req,res,objs,postMapper,next);
};
/* not js-doc, don't want in output.
* Translates an Odata $orderby clause into a mongo version.
* http://www.odata.org/documentation/odata-version-2-0/uri-conventions/ (section 4.2)
*
* @param {String} $orderby The external odata $orderby clause
* @return {String} The string equivalent of mongoose sort.
*/
function odataOrderBy($orderby) {
if($orderby) {
var mongo,
clauseExp = /^([^\s]+)\s*(asc|desc|)$/;
$orderby.split(',').forEach(function(clause) {
var clause_parts = clauseExp.exec(clause.trim());
if(!clause_parts) {
debug('orderby clause "%s" invalid, ignoring.',clause);
} else {
var field = clause_parts[1],
direction = clause_parts[2];
if(direction === 'desc') {
field = '-'+field;
}
mongo = mongo ? (mongo+' '+field) : field;
}
});
debug('translated odata orderby "%s" to mongo sort "%s"',$orderby,mongo);
return mongo;
}
}
/**
* Initializes a mongoose query from a user request.
*
* @param {Object} query The mongoose query.
* @param {Object} req The express request object.
* @return {Object} The mongoose query (query input argument).
*/
Resource.prototype.initQuery = function(query,req) {
var base = this.getDefinition(),
def = _.extend({
$orderbyPaged: '_id',
$expand: base.populate // populate is deprecated, if set its the default for $expand
},base,req.query),
populate = def.$expand ?
(_.isArray(def.$expand) ? def.$expand : [def.$expand]) : [];
populate.forEach(function(att){
if(typeof(att) === 'string') {
att.split(',').forEach(function(a) {
a = a.trim();
// nested expand, needs to be turned into an object instructing
// which paths/nested paths to expand
if(a.indexOf('.') !== -1) {
var parts = a.split('.'),
pop = { path: parts[0] },cpop = pop,i;
for(i = 1; i < parts.length; i++) {
cpop.populate = { path: parts[i] };
cpop = cpop.populate;
}
a = pop;
}
query.populate(a);
});
} else {
query.populate(att);
}
});
if(base.$select) {
// don't let the caller over-ride to gain access to
// fields that weren't intended.
def.$select = base.$select;
}
if(def.$select) {
query.select(def.$select);
}
if(typeof(def.lean) === 'boolean') {
query.lean(def.lean);
} else {
query.lean(true); // by default go straight to a JavaScript object
}
if(def.$top) {
query.limit(+def.$top);
}
if(def.$skip) {
query.skip(+def.$skip);
}
if(def.$orderby) {
query.sort(odataOrderBy(def.$orderby));
} else if (def.$top) {
// per the odata spec if the client is paging but not sorting then
// we must impose a sort order to ensure responses are repeatable and
// paged results are valid, _id is the only attribute we can count on
// existing so sort on it.
query.sort(def.$orderbyPaged);
}
if(def.$filter) {
filterParser(query,def.$filter);
}
// save the query definiton for later re-use.
req.$odataQueryDefinition = def;
return query;
};
/**
* <p>Builds a 'mapper' object that can be used to translate mongoose objects into
* REST response objects. This function can be passed to Array.map given an array of
* mongoose objects. All object results returned to a client should pass through a
* mapper so that meta information like instance links can be attached.</p>
*
* <p><em>Note:</em> When sending custom responses you should use the [listResponse]{@link Resource#listResponse} or [singleResponse]{@link Resource#singleResponse} functions to do
* so and those functions implicitly use a mapper.</p>
*
* @param {function} postMapper Array.map callback that should be called after the underlying mapper does its work (optional).
* @return {function} An Array.map callback.
*/
Resource.prototype.getMapper = function(postMapper) {
var model = this.getModel(),
instanceLinkNames = this.getInstanceLinkNames(),
rel = this.getRel();
return function(o,i,arr) {
if(typeof(o.toObject) === 'function') {
if(!i) {
// just log for single maps, or the first in an array.
debug('%s: translating mongoose model to a pojo',rel);
}
o = o.toObject();
}
var selfLink = rel+'/'+o._id,
links = {
self: selfLink
};
instanceLinkNames.forEach(function(link) {
links[link] = selfLink+'/'+link
});
o._links = links;
return typeof(postMapper) === 'function' ? postMapper(o,i,arr) : o;
};
};
/**
* Fetches and returns to the client an entity by id. Resources may
* override this default functionality.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.findById = function(req,res,next) {
var self = this,
def = this.getDefinition();
query = this.initQuery(self.getModel().findById(req._resourceId),req);
query.exec(function(err,obj){
if(err || !obj) {
Resource.sendError(res,404,'not found',err);
} else {
self.singleResponse(req,res,obj,null,next);
}
});
};
/**
* Executes a query for an entity type and returns the response to the client.
* Resources may override this default functionality.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.find = function(req,res,next) {
var self = this,
def = this.getDefinition(),
query = this.initQuery(self.getModel().find(),req);
query.exec(function(err,objs){
if(err){
Resource.sendError(res,500,'find failed',err);
} else {
debug('found %d objects.', objs.length);
self._findListResponse(req,res,objs,null,next);
}
});
};
/**
* Executes a query for an entity type and returns the number of objects found.
* Resources may override this default functionality.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
*/
Resource.prototype.count = function(req,res) {
var self = this,
def = this.getDefinition(),
query = this.initQuery(self.getModel().find(),req);
query.count(function(err,n){
if(err){
Resource.sendError(res,500,'find failed',err);
} else {
res.json(n);
}
});
};
/**
* Creates an instance of this entity type and returns the newly created
* object to the client.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.create = function(req,res,next) {
var self = this,
Model = self.getModel(),
instance = new Model(req.body);
instance.save(function(err,saved){
if(err) {
return Resource.sendError(res,500,'create failure',err,next);
}
// self.singleResponse(req,res,saved);
// re-fetch the object so that nested attributes are properly populated.
req._resourceId = saved._id;
self.findById(req,res,next);
});
};
/**
* <p>Updates an instance of this entity type and returns the updated
* object to the client.</p>
*
* <p><em>Note:</em> This implementation of update is more similar to PATCH in that
* it doesn't require a complete object to update. It will accept a sparsely populated
* input object and update only the keys found within that object.</p>
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Function} [next] Optional next callback to invoke after the response is sent with the response object.
*/
Resource.prototype.update = function(req,res,next) {
var self = this,
model = self.getModel();
// not using findOneAndUpdate because helpers are not applied
model.findOne({_id: req._resourceId},function(err,obj){
if(err) {
return Resource.sendError(res,404,'not found',err,next);
}
Object.keys(req.body).forEach(function(key){
obj[key] = req.body[key];
});
obj.save(function(err,obj) {
if(err) {
return Resource.sendError(res,500,'update failure',err,next);
}
// re-fetch the object so that nested attributes are properly populated.
self.findById(req,res,next);
});
});
};
/**
* Deletes an instance of this entity type.
*
* @param {Object} req The express request object.
* @param {Object} res The express response object.
* @param {Function} [next] Optional next callback to invoke after successful delete with the model object.
*/
Resource.prototype.delete = function(req,res,next) {
var self = this,
query = self.initQuery(self.getModel().findById(req._resourceId),req);
query.lean(false); // need the object itself regardless of how the resource is defined
query.exec(function(err,obj){
if(err || !obj) {
return Resource.sendError(res,404,'not found',err,next);
}
obj.remove(function(err,obj){
if(err) {
return Resource.sendError(res,500,'remove error',err,next);
}
res.status(200).send();
if(typeof(next) === 'function') {
next(null,obj);
}
});
});
};
/**
* Add a static link implementation to this resource.
*
* @param {String} rel The relative path of the link.
* @param {function} link A function to call when the static link is requested. The
* arguments are (req,res) which are the express request and response
* objects respectively.
* @return {Object} this
*/
Resource.prototype.staticLink = function(rel,link) {
// for now static links are functions only
var def = this._definition,
links = def.staticLinks;
if(!links) {
links = def.staticLinks = {};
}
links[rel] = link;
return this;
};
/**
* Add an instance link to this resource.
*
* The link argument can be either an object defining a simple relationship
* (based on a reference from the 'other side' object) or a function to be called to
* resolve the relationship.
*
* If a function is supplied then its arguments must be (req,res) which are the express
* request and response objects respectively.
*
* If an object is supplied then the necessary keys are:
* - otherSide (Object): The Resource instance of the other side entity.
* - key (string): The attribute name on the otherside object containing the id of this Resource's entity type.
*
* @param {String} rel The relative path of the link.
* @param {Object|function} link The link definition.
* @return {Object} this
*/
Resource.prototype.instanceLink = function(rel,link) {
var def = this._definition,
links = def.instanceLinks;
if(!links) {
links = def.instanceLinks = {};
}
links[rel] = link;
return this;
}
/**
* Initializes and returns an express router.
* If app is supplied then app.use is called to bind the
* resource's 'rel' to the router.
*
* @param {object} app Express app (optional).
* @return {object} Express router configured for this resource.
*/
Resource.prototype.initRouter = function(app) {
var self = this,
resource = self._definition,
router = express.Router();
if(resource.staticLinks) {
Object.keys(resource.staticLinks).forEach(function(link){
var linkObj = resource.staticLinks[link],
linkObjType = typeof(linkObj);
if(linkObjType === 'function') {
router.get('/'+link,(function(self){
return function(req,res) {
linkObj.apply(self,arguments);
};
})(self));
}
});
}
router.param('id',function(req,res,next,id){
req._resourceId = id;
next();
});
if(typeof(resource.create) === 'undefined' || resource.create) {
router.post('/',(function(self){
return function(req,res) {
self.create(req,res);
};
})(this));
}
if(typeof(resource.update) === 'undefined' || resource.update) {
router.put('/:id',(function(self){
return function(req,res) {
self.update(req,res);
};
})(this));
}
if(typeof(resource.delete) === 'undefined' || resource.delete) {
router.delete('/:id',(function(self){
return function(req,res) {
self.delete(req,res);
};
})(this));
}
router.get('/:id',(function(self){
return function(req,res) {
self.findById(req,res);
};
})(this));
router.get('/', (function(self){
return function(req,res) {
self.find(req,res);
};
})(this));
if(resource.instanceLinks) {
Object.keys(resource.instanceLinks).forEach(function(link){
var linkObj = resource.instanceLinks[link],
linkObjType = typeof(linkObj);
if(linkObjType === 'function') {
router.get('/:id/'+link,(function(self){
return function(req,res) {
resource.instanceLinks[link].apply(self,arguments);
};
})(self));
} else if(linkObj.otherSide instanceof Resource && linkObj.key) {
if(linkObj.otherSide.getDefinition().count) {
router.get('/:id/'+link+'/count',
(function(self,otherSide,key) {
return function(req,res) {
var criteria = {};
criteria[key] = req._resourceId;
var query = otherSide.initQuery(otherSide.getModel().find(criteria),req);
query.count(function(err,n){
if(err) {
return Resource.sendError(res,500,'error resolving relationship',err)
}
res.json(n);
});
};
})(self,linkObj.otherSide,linkObj.key));
}
router.get('/:id/'+link,
(function(self,otherSide,key) {
return function(req,res) {
var criteria = {};
criteria[key] = req._resourceId;
var query = otherSide.initQuery(otherSide.getModel().find(criteria),req);
query.exec(function(err,objs){
if(err) {
return Resource.sendError(res,500,'error resolving relationship',err)
}
otherSide.relListResponse(req,res,objs);
});
};
})(self,linkObj.otherSide,linkObj.key));
}
});
}
if(app) {
app.use(this.getRel(),router);
}
return router;
};
module.exports = Resource;