jest
Version:
JavaScriptational State Transfer. JS restful API layer with Mongoose based resources. Inspired by python Tastypie
933 lines (854 loc) • 31.4 kB
JavaScript
var _ = require('underscore'),
Class = require('sji'),
Authentication = require('./authentication'),
Authorization = require('./authorization'),
Cache = require('./cache'),
Throttling = require('./throttling'),
Validation = require('./validation');
var NotImplemented = function(){
Error.call(this, 'Method not implemented', 865);
};
NotImplemented.prototype = new Error();
NotImplemented.prototype.constructor = NotImplemented;
NotImplemented.prototype.name = 'NotImplemented';
var Resource = module.exports = Class.extend({
/**
* constructor
*/
init:function () {
// allowed methods tree, can contain 'get','post','put','delete'
this.allowed_methods = ['get'];
// the authentication class to use (default:no authentication)
this.authentication = new Authentication();
// the authorization class to use (default: no authorization)
this.authorization = new Authorization();
// cache mechanizem ( default:no cache)
this.cache = new Cache();
// validation mechanizem (default: no validation)
this.validation = new Validation();
// throttling engine (default: no throttling)
this.throttling = new Throttling();
// fields uppon filtering is allowed
this.filtering = {};
// fields uppon sorting is allowed
this.sorting = null;
// fields that can be updated/created
this.update_fields = null;
// fields than can't be updated (stronger)
this.update_exclude_fields = null;
// fields which are exposable
this.fields = null;
// default quering limit
this.default_limit = null;
// max results to return
this.max_limit = null;
// TBD
this.strict = false;
},
/**
* called on GET - /<resource>/:resource_id
*
* @param req
* @param res
*/
show:function (req, res) {
var self = this;
return self.dispatch(req, res, function (req, callback) {
// get the object by id
self.cached_get_object(req, req._id, function(err,object){
if(err)
callback(err);
else
{
if(!object)
callback({code:404,message:'couldn\'t find object with id ' + req._id});
else
callback(null,object);
}
});
});
},
/**
* called on GET - /<resource>/
*
* @param req
* @param res
*/
index:function (req, res) {
var self = this;
return self.dispatch(req, res, function (req, callback) {
// parse query params
var filters = self.build_filters(req.query);
var sorts = self.build_sorts(req.query);
if(typeof(filters) == 'string' || typeof(sorts) == 'string')
{
callback({code:400,message:filters});
return;
}
var offset = Number(req.query['offset'] || 0);
var limit = Number(req.query['limit'] || self.default_limit || self.settings.DEFAULT_LIMIT);
var max_limit = self.max_limit || self.settings.MAX_LIMIT;
if(limit > max_limit)
{
if(self.strict)
return callback({code:400, message:'limit can be more than ' + max_limit})
else
limit = max_limit;
}
limit = Math.min(limit, self.max_limit || self.settings.MAX_LIMIT);
// if (limit <= 0)
// {
// if(self.strict)
// return callback({code:400, message:'limit must be a greater than zero'});
// else
// limit = self.max_limit || self.settings.MAX_LIMIT;
// }
// check if in cache
var cached_key = self.build_cache_key(req.query);
self.cache.get(cached_key, function (err, objects) {
if (err) callback(err);
else {
// if in cache, returns cached results
if (objects)
callback(null, objects);
else
// if not get from DB
self.get_objects(req, filters, sorts, limit, offset, function (err, objects) {
if (err) callback(err);
else {
// set in cache. don't wait for response
self.cache.set(cached_key, objects, function (err) {
});
callback(null, objects);
}
});
}
});
});
},
/**
* called on POST - /<resource>/
*
* @param req
* @param res
*/
create:function (req, res) {
var self = this;
return self.dispatch(req, res, function (req, callback) {
// get request fields, parse & limit them
var fields = self.hydrate(req.body,self.get_update_tree(req), self.get_update_exclude_tree(req));
// validate object
self.validation.is_valid(fields, function (err, errors) {
if (err) callback(err);
else {
if (errors && Object.keys(errors).length > 0) {
callback({code:400, message:errors, content:'json'});
}
else {
// save objects
self.create_obj(req, fields, callback);
}
}
});
});
},
/**
* called on PUT - /<resource>/:resource_id
*
* @param req
* @param res
*/
update:function (req, res) {
var self = this;
return self.dispatch(req, res, function (req, callback) {
// get the object by the url id
self.get_object(req, req._id, function (err, object) {
if (err) callback(err);
else {
if(!object)
{
callback({code:404,message:'object doesn\'t exists'});
return;
}
// get request fields, parse & limit them
var fields = self.hydrate(req.body,self.get_update_tree(req), self.get_update_exclude_tree(req));
// Use edit object if exists
if(self.edit_obj){
self.edit_obj(req,object,fields,function(err,object){
if (err)
callback(err);
else {
// save to cache, this time wait for response
self.cache.set(self.build_cache_key(req._id), object, function (err) {
if (err) callback(err);
else callback(null, object);
});
}
});
return;
}
// if not, set values yourself
self.setValues(object,fields);
// validate object
self.validation.is_valid(object, function (err, errors) {
if (err) callback(err);
else {
if (errors && Object.keys(errors).length > 0) {
callback({code:400, message:errors, content:'json'});
}
else {
// save the modified object
self.update_obj(req, object, function (err, object) {
if (err)
callback(err);
else {
// save to cache, this time wait for response
self.cache.set(self.build_cache_key(req._id), object, function (err) {
if (err) callback(err);
else callback(null, object);
});
}
});
}
}
});
}
});
});
},
/**
* called on DELETE - /<resource>/:resource_id
*
* @param req
* @param res
*/
destroy:function (req, res) {
var self = this;
return self.dispatch(req, res, function (req, callback) {
// get the object to delete by the url id
self.get_object(req, req._id, function (err, object) {
if (err) callback(err);
else {
if(!object)
{
callback({code:404,message:'object doesn\'t exists'});
return;
}
// delete the object from DB
self.delete_obj(req, object, callback);
// delete the object from cache
self.cache.set(self.build_cache_key(req._id), null, function () {
});
}
});
});
},
/**
* Sets values from fields in object
* @param object
* @param fields
*/
setValues:function(object,fields) {
// updates the object with the given fields
for (var field in fields) {
// if the value is an object, not an array, extend recursively
if(fields[field] && typeof(fields[field]) == 'object' && !Array.isArray(fields[field])) {
if(!object[field])
object[field] = fields[field];
else
this.setValues(object[field],fields[field]);
}
else {
if (typeof(object.set) == 'function')
object.set(field, fields[field]);
else
object[field] = fields[field];
}
}
},
/**
* set the entity id on request._id
*
* @param req
* @param id
* @param fn
*/
load:function (req, id, fn) {
req._id = id;
fn(null, id);
},
/***************************** Error Responses ******************************************
*
*/
/**
* send unautherized response
*
* @param res
* @param message
*/
unauthorized:function (res, message) {
if (message)
res.send(message, 401);
else
res.send(401);
},
/**
* send bad request response
*
* @param res
* @param json
*/
bad_request:function (res, json) {
res.json(json.message || json, 400);
},
/**
* send internal server error response
*
* @param err
* @param req
* @param res
*/
internal_error: function(err, req, res) {
var message = (err.message || err);
var code = (err.code || 500);
console.trace("jest internal error: " + message);
res.send(message, code);
},
/***************************** Help functions ******************************************
*
*/
/**
* gets the allowed methods object
*/
get_allowed_methods_tree: function () {
if(_.isArray(this.allowed_methods)){
var tree = {};
_.each(this.allowed_methods, function(method){
if(method == 'get')
tree[method] = { 'details':true, 'list':true };
else
tree[method] = true;
});
this.allowed_methods = tree;
}
return this.allowed_methods;
},
make_field_tree:function(fields)
{
var tree = null;
if (fields) {
if (Array.isArray(fields)) {
tree = {};
for (var i = 0; i < fields.length; i++) {
tree[fields[i]] = null;
}
}
else
tree = fields;
}
return tree;
},
/**
* gets the exposable fields tree
*/
get_tree:function (req) {
var fields = req.jest_fields || this.fields;
return this.make_field_tree(fields);
},
/**
* gets the editable fields tree
*/
get_update_tree:function (req) {
var fields = req.jest_update_fields || this.update_fields;
return this.make_field_tree(fields);
},
get_update_exclude_tree: function(req){
var fields = req.jest_update_exclude_fields || this.update_exclude_fields;
return this.make_field_tree(fields);
},
/**
* goes over response objects & hide all fields that aren't in this.fields. Turns all objects to basic types (Number,String,Array,Object)
*
* @param objs
*/
full_dehydrate:function (req,objs) {
if (objs && typeof(objs) == 'object' && 'meta' in objs && 'objects' in objs) {
objs.objects = this.dehydrate(objs.objects,this.get_tree(req));
return objs;
}
else
return this.dehydrate(objs,this.get_tree(req));
},
/**
* same as full_dehydrate
*
* @param object
* @param tree
*/
dehydrate:function (object, tree,parent_object) {
if(!object)
return object;
// if an array -> dehydrate each object independently
if (Array.isArray(object)) {
var objects = [];
for (var i = 0; i < object.length; i++) {
objects.push(this.dehydrate(object[i], tree,parent_object));
}
return objects;
}
if(typeof(object) == 'function')
return object.call(parent_object);
// if basic type return as is
if (typeof(object) != 'object')
return object;
// parse known types
if (object instanceof Number)
return this.dehydrate_number(object);
if (object instanceof Date)
return this.dehydrate_date(object);
// object is a dict {}
// gets the exposeable fields tree
if (!tree)
return object;
var new_object = {};
for (var field in tree) {
// recursively dehydrate children
if (typeof(object.get) == 'function')
new_object[field] = this.dehydrate(object.get(field) || object[field], tree[field],object);
else
new_object[field] = this.dehydrate(object[field], tree[field],object);
}
return new_object;
},
/**
* parse number
*
* @param num
*/
dehydrate_number:function (num) {
return Number(num);
},
/**
* parse date
*
* @param date
*/
dehydrate_date:function (date) {
return date;
},
deserializeJsonp: function(req,res,object,status) {
res.header('Cache-Control','no-cache');
res.header('Pragma','no-cache');
res.header('Expires','-1');
res.jsonp(object, status);
},
deserializeJson : function(req,res,object,status) {
res.header('Cache-Control','no-cache');
res.header('Pragma','no-cache');
res.header('Expires','-1');
res.json(object, status);
},
/**
* converts response basic types object to response string
*
* @param req
* @param res
* @param object
* @param status
*/
deserialize:function (req, res, object, status) {
// TODO negotiate response content type
// Check if callback is defined. If so then respond jsonp
var callback = (req.query && req.query.callback) || (req.body && req.body.callback);
if(callback) {
this.deserializeJsonp(req, res, object, status);
return;
}
this.deserializeJson(req, res, object, status);
},
/**
* performs all API routeen checks before calling 'func', getting 'func' callback with object, and handles response object
*
* @param req
* @param res
* @param main_func
*/
dispatch:function (req, res, main_func) {
var self = this;
// check if method is allowed
var method = req.method.toLowerCase();
var allowed_methods = self.get_allowed_methods_tree();
if (!( method in allowed_methods )) {
self.unauthorized(res);
return;
}
else
{
if(allowed_methods[method] == 'get')
{
var is_list = req._id ? 'details' : 'list';
if(!(is_list in allowed_methods))
{
self.unauthorized(res);
return;
}
}
}
// check authentication
self.authentication.is_authenticated(req, function (err, is_auth) {
if (err)
self.unauthorized(res,err.message || err);
else {
if (is_auth === false) {
self.unauthorized(res,'not authenticated');
return;
}
// check throttleing
self.throttling.throttle(self.authentication.get_request_identifier(req), function (err, is_throttle) {
if (err) {
self.internal_error(err, req, res);
return;
}
if (is_throttle) {
self.unauthorized(res);
return;
}
self.authorization.is_authorized(req, function (err, is_auth) {
if (err) {
self.internal_error(err, req, res);
return;
}
if (!is_auth) {
self.unauthorized(res);
return;
}
// main function
main_func(req, function (err, response_obj) {
if (err) {
// error can be with error code
if (err.code) {
if (err.code == 500) {
self.internal_error(err, req, res);
}
else if (err.code == 400) {
self.bad_request(res, err);
}
else if (err.code == 401) {
self.unauthorized(res, err.message);
}
else if (err.message && err.message.match(/duplicate key/gi)) {
res.json(err.message, 400);
}
else {
res.json(err.message, err.code);
}
}
else {
// mongoose errors usually
if (err.errors)
self.bad_request(res, err.errors);
else
self.internal_error(err, req, res);
}
return;
}
// dehydrate resopnse object
response_obj = self.full_dehydrate(req,response_obj);
var status;
switch (method) {
case 'get':
status = 200;
break;
case 'post':
status = 201;
break;
case 'put':
status = 201;
break;
case 'delete':
status = 204;
break;
}
// send response
self.deserialize(req, res, response_obj, status);
});
});
});
}
});
},
escape_regex:function(str) {
return (str+'').replace(/([.*?+^$[\]\\(){}|-])/g, "\\$1");
},
/**
* builds filtering objects from query string params
*
* @param query
*/
build_filters:function (query) {
var filters = {};
var or_filter = [], nor_filter = [];
for (var field in query) {
// check for querying operators
var parts = field.split('__');
var field_name = parts[0];
var operand = parts.length > 1 ? parts[1] : 'exact';
if (field_name in this.filtering)
{
if(this.filtering[field_name] && typeof(this.filtering[field_name]) == 'object')
{
if(operand in this.filtering[field_name])
filters[field] = query[field];
else {
if(this.strict)
return 'filter ' + field_name + ' with operand ' + operand + ' is not allowed. see allowed filters in schema';
else
continue; }
}
else
filters[field] = query[field];
}
else
{
if(field != 'or' && field != 'nor' && field != 'limit' && field != 'offset' && field != 'order_by')
{
if(this.strict)
return 'filter ' + field_name + ' is not allowed. see allowed filters in schema';
else
continue;
}
}
// support 'in' query
if (operand == 'in')
filters[field] = query[field].split(',');
if(operand == 'near')
{
try{
var json = JSON.parse(query[field]);
if(json && json.lat && json.lng)
filters[field] = {lng:Number(json.lng), lat:Number(json.lat)};
else
return 'near filter only accepts two params: lat,lng as a list (i.e [23.32,43.231] ) or an object (i.e {"lat":23.32,"lng":43})';
}
catch (e) {
filters[field] = query[field].split(',');
if(filters[field].length != 2)
return 'near filter only accepts two params: lat,lng as a list (i.e [23.32,43.231] ) or an object (i.e {"lat":23.32,"lng":43})';
filters[field] = {lng:Number(filters[field][1]), lat:Number(filters[field][0])};
}
}
// support regex operators
if(operand == 'contains') {
filters[field.replace('__contains','')] = new RegExp(this.escape_regex(filters[field]));
delete filters[field];
}
if(operand == 'startswith') {
filters[field.replace('__startswith','')] = new RegExp('^' + this.escape_regex(filters[field]));
delete filters[field];
}
if(operand == 'endswith') {
filters[field.replace('__endswith','')] = new RegExp(this.escape_regex(filters[field]) + '$');
delete filters[field];
}
if(operand == 'iexact') {
filters[field.replace('__iexact','')] = new RegExp('^' + this.escape_regex(filters[field]) + '$','i');
delete filters[field];
}
if(operand == 'icontains') {
filters[field.replace('__icontains','')] = new RegExp(this.escape_regex(filters[field]),'i');
delete filters[field];
}
if(operand == 'istartswith') {
filters[field.replace('__istartswith','')] = new RegExp('^' + this.escape_regex(filters[field]),'i');
delete filters[field];
}
if(operand == 'iendswith') {
filters[field.replace('__iendswith','')] = new RegExp(this.escape_regex(filters[field]) + '$','i');
delete filters[field];
}
if (field == 'or')
or_filter = query[field].split(',');
if (field == 'nor')
nor_filter = query[field].split(',');
}
if (or_filter.length) {
filters['or'] = [];
for (var i = 0; i < or_filter.length; i++) {
if (or_filter[i] in filters) {
var qry = {};
qry[or_filter[i]] = filters[or_filter[i]];
filters['or'].push(qry);
delete filters[or_filter[i]];
}
}
}
if (nor_filter.length) {
filters['nor'] = [];
for (var i = 0; i < nor_filter.length; i++) {
if (nor_filter[i] in filters) {
var qry = {};
qry[nor_filter[i]] = filters[nor_filter[i]];
filters['nor'].push(qry);
delete filters[nor_filter[i]];
}
}
}
return filters;
},
/**
* builds the sorting objects from query string params
*
* @param query
*/
build_sorts:function (query) {
var sorting = query['order_by'];
if (sorting) {
sorting = sorting.split(',');
var sorts = [];
for (var i = 0; i < sorting.length; i++) {
var asec = sorting[i][0] != '-';
if (sorting[i][0] == '-')
sorting[i] = sorting[i].substr(1);
if(!this.sorting || sorting[i] in this.sorting)
sorts.push({field:sorting[i], type:asec ? 1 : -1});
}
return sorts;
}
return [];
},
/**
* build cache key from query params
*
* @param id_query
*/
build_cache_key:function (id_query) {
var key = id_query;
if (typeof(id_query) == 'object') {
key = '';
for (var field in id_query)
key += field + '=' + id_query[field];
}
key = this.path + key;
return key;
},
/**
* get object with cache wrapping
*
* @param req
* @param id
* @param callback
*/
cached_get_object:function (req, id, callback) {
var self = this;
// get from cache
var cache_key = self.build_cache_key(id);
self.cache.get(cache_key, function (err, object) {
if (err) {
callback(err);
return;
}
// if returned from cache return it
if (object) callback(null, object);
else
self.get_object(req, id, function (err, object) {
if (err) callback(err);
else {
self.cache.set(cache_key, object, function () {
});
callback(null, object);
}
});
});
},
/**
* parses request body + makes sure only allowed field are passed on (from this.update_fields/this.update_tree)
*
* @param object
* @param tree
*/
hydrate:function (object, tree,exclude_tree) {
if (Array.isArray(object)) {
var objects = [];
for (var i = 0; i < object.length; i++) {
objects.push(this.hydrate(object[i], tree,exclude_tree));
}
return objects;
}
if (typeof(object) != 'object' || object === null)
return object;
var new_object = {};
var tree_empty = tree ? true : false;
var exclude_tree_empty = exclude_tree ? true : false;
tree = tree || {};
exclude_tree = exclude_tree || {};
for (var field in object)
{
if(!tree_empty || field in tree)
{
if(!exclude_tree_empty || !(field in exclude_tree))
new_object[field] = this.hydrate(object[field], tree[field],exclude_tree[field]);
}
}
return new_object;
},
show_fields:function(){
return this.fields || [];
},
show_update_fields:function() {
return this.update_fields || this.show_fields();
},
// Methods to implemenet
/**
* single object getter. (called on - show,update,delete)
*
* @param req
* @param id
* @param callback
*/
get_object:function (req, id, callback) {
throw new NotImplemented();
},
/**
* multiple object getter. called on - index
*
* @param req
* @param filters
* @param sorts
* @param limit
* @param offset
* @param callback
*/
get_objects:function (req, filters, sorts, limit, offset, callback) {
throw new NotImplemented();
},
/**
* save new object with fields. called on - create
*
* @param req
* @param fields
* @param callback
*/
create_obj:function (req, fields, callback) {
throw new NotImplemented();
},
/**
* save existing object. called on - update
*
* @param req
* @param object
* @param callback
*/
update_obj:function (req, object, callback) {
throw new NotImplemented();
},
/**
* delete object. called on - delete
*
* @param req
* @param object
* @param callback
*/
delete_obj:function (req, object, callback) {
throw new NotImplemented();
}
});