Skip to content

Instantly share code, notes, and snippets.

@naholyr
Created August 27, 2011 10:13
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save naholyr/1175210 to your computer and use it in GitHub Desktop.
REST avec NodeJS & Express - Application
var express = require('express')
, app = module.exports = express.createServer()
app.configure(function () {
app.set('views', __dirname + '/views');
app.set('view engine', 'jade');
app.use(app.router);
app.use(express.static(__dirname + '/public'));
});
app.configure('development', function () {
app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
});
app.configure('production', function () {
app.use(express.errorHandler());
});
// Montage de l'API REST sur /bookmarks
app.use('/bookmarks', app.bookmarks_app = require('./bookmarks-rest')());
// Homepage
app.get('/', function (req, res) {
res.render('index', { "title": 'Bookmarks' });
});
if (module.parent === null) {
app.listen(3000);
console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env);
}
var express = require('express')
, m = require('./middleware')
// Instanciated module
module.exports = function () {
var app = express.createServer();
app.db = require('./db')();
app.on('close', app.db.close);
app.configure(function () {
app.param('id', m.checkIdParameter);
app.use(m.checkRequestHeaders);
app.use(express.bodyParser());
app.use(m.handleBodyParserError);
app.use(m.checkRequestData);
app.use(express.methodOverride());
app.use(app.router);
});
app.configure('development', function () {
app.use(m.errorHandler({"stack": true}));
});
app.configure('production', function () {
app.use(m.errorHandler());
});
app.configure('test', function () {
app.use(m.errorHandler({"stack": false, "log": function showNothing(){}});
});
app.post('/', m.dbAction(db, 'save'));
app.get( '/', m.dbAction(db, 'fetchAll', function (ids) { return ids.map(function (id) {
// URIs depend on mount route
return app.route + (app.route.charAt(app.route.length-1) == '/' ? '' : '/') + 'bookmark/' + id; }); }));
app.get( '/bookmark/:id', m.dbAction(db, 'fetchOne'));
app.put( '/bookmark/:id', m.dbAction(db, 'save'));
app.del( '/', m.dbAction(db, 'deleteAll'));
app.del( '/bookmark/:id', m.dbAction(db, 'deleteOne'));
app.all( '/*', function (req, res, next) { next({"code":405, "message":"Method not allowed"}); });
return app;
}
// Expose dependencies to avoid duplicate modules
exports.express = express;
exports.middlewares = m;
// Start when main module
if (module.parent == null) module.exports().listen(3000);
module.exports = function (options) {
/**
* Module options
*/
var client = require('redis').createClient()
, namespace = 'bookmarks';
if ('undefined' != typeof options) _set_options_(options)
/**
* Privates
*/
// Get bookmark key name
function _key_ (id) {
return namespace + ':' + id + ':json';
}
// Get sequence key name
function _seq_ () {
return namespace + '::sequence';
}
// Update internal options
function _set_options_ (options) {
if ('undefined' != typeof options.database) client.select(options.database);
if ('undefined' != typeof options.namespace) namespace = options.namespace;
return this;
}
return {
/**
* Update options
*/
"configure": _set_options_,
/**
* Allow disconnection
*/
"close": function disconnect (callback) {
if (client.connected) client.quit();
if (callback) client.on('close', callback);
},
/**
* Save a bookmark
* if bookmark has no attribute "id", it's an insertion, else it's an update
* callback is called with (err, bookmark, created)
*/
"save": function save (bookmark, callback) {
var created = ('undefined' == typeof bookmark.id);
var self = this;
var onIdReady = function () {
client.set(_key_(bookmark.id), JSON.stringify(bookmark), function (err) {
callback(err, bookmark, created);
});
}
if (created) { // No ID: generate one
client.incr(_seq_(), function (err, id) {
if (err) return callback(err);
bookmark.id = id;
onIdReady();
});
} else { // ID already defined: it's an update
this.fetchOne(bookmark.id, function (err, old) {
if (err) return callback(err);
for (var attr in bookmark) {
old[attr] = bookmark[attr];
}
bookmark = old;
onIdReady();
});
}
},
/**
* Retrieve a bookmark
* callback is called with (err, bookmark)
* if no bookmark is found, an error is raised with type=ENOTFOUND
*/
"fetchOne": function fetchOne (id, callback) {
client.get(_key_(id), function (err, value) {
if (!err && !value) err = {"message": "Bookmark not found", "type":"ENOTFOUND"};
if (err) return callback(err);
var bookmark = null;
try {
bookmark = JSON.parse(value);
} catch (e) {
return callback(e);
}
return callback(undefined, bookmark);
});
},
/**
* Retrieve all IDs
* callback is called with (err, bookmarks)
*/
"fetchAll": function fetchAll (callback) {
client.keys(_key_('*'), function (err, keys) {
if (err) return callback(err);
callback(undefined, keys.map(function (key) {
return parseInt(key.substring(namespace.length+1));
}));
});
},
/**
* Delete a bookmark
* callback is called with (err, deleted)
*/
"deleteOne": function deleteOne (id, callback) {
client.del(_key_(id), function (err, deleted) {
if (!err && deleted == 0) err = {"message": "Bookmark not found", "type":"ENOTFOUND"};
callback(err, deleted > 0);
});
},
/**
* Flush the whole bookmarks database
* Note that it doesn't call "flushAll", so only "bookmarks" entries will be removed
* callback is called with (err, deleted)
*/
"deleteAll": function deleteAll (callback) {
var self = this;
client.keys(_key_('*'), function (err, keys) {
if (err) return callback(err);
var deleteSequence = function deleteSequence (err, deleted) {
if (err) return callback(err);
client.del(_seq_(), function (err, seq_deleted) {
callback(err, deleted > 0 || seq_deleted > 0);
});
}
if (keys.length) {
client.del(keys, deleteSequence);
} else {
deleteSequence(undefined, 0);
}
});
}
}
};
/**
* Module dependencies
*/
var contracts = require('contracts');
require('./response'); // Monkey patch
/**
* Options:
* - stack: show stack in error message ?
* - log: logging function
*/
exports.errorHandler = function (options) {
var log = options.log || console.error
, stack = options.stack || false
return function (err, req, res, next) {
log(err.message);
if (err.stack) log(err.stack);
var content = err.message;
if (stack && err.stack) content += '\n' + err.stack;
var code = err.code || (err.type == 'ENOTFOUND' ? 404 : 500);
res.respond(content, code);
}
}
/**
* Checks Accept and Content-Type headers
*/
exports.checkRequestHeaders = function (req, res, next) {
if (!req.accepts('application/json'))
return res.respond('You must accept content-type application/json', 406);
if ((req.method == 'PUT' || req.method == 'POST') && req.header('content-type') != 'application/json')
return res.respond('You must declare your content-type as application/json', 406);
return next();
}
/**
* Validates bookmark
*/
exports.checkRequestData = function (req, res, next) {
if (req.method == 'POST' || req.method == 'PUT') {
// Body expected for those methods
if (!req.body) return res.respond('Data expected', 400);
var required = req.method == 'POST'; // PUT = partial objects allowed
// Validate JSON schema against our object
var report = contracts.validate(req.body, {
"type": "object",
"additionalProperties": false,
"properties": {
"url": { "type": "string", "required": required, "format": "url" },
"title": { "type": "string", "required": required },
"tags": { "type": "array", "items": { "type": "string" }, "required": false }
}
});
// Respond with 400 and detailed errors if applicable
if (report.errors.length > 0) {
return res.respond('Invalid data: ' + report.errors.map(function (error) {
var message = error.message;
if (~error.uri.indexOf('/')) {
message += ' (' + error.uri.substring(error.uri.indexOf('/')+1) + ')';
}
return message;
}).join(', ') + '.', 400);
}
}
next();
}
/**
* Catch and transform bodyParser SyntaxError
*/
exports.handleBodyParserError = function (err, req, res, next) {
if (err instanceof SyntaxError) res.respond(err, 400);
else next(err);
}
/**
* Work on parameter "id", depending on method
*/
exports.checkIdParameter = function (req, res, next, id) {
if (isNaN(parseInt(id))) {
return next({"message": "ID must be a valid integer", "code": 400});
}
// Update
if (req.method == 'PUT') {
if ('undefined' == typeof req.body.id) {
req.body.id = req.param('id'); // Undefined, use URL
} else if (req.body.id != req.param('id')) {
return next({"message": "Invalid bookmark ID", "code": 400}); // Defined, and inconsistent with URL
}
}
// Create
if (req.method == 'POST') {
if ('undefined' != typeof req.body.id) {
return next({"message": "Bookmark ID must not be defined", "code": 400});
}
}
// Everything went OK
next();
}
/**
* Middleware defining an action on DB
* @param action The action ("save", "deleteOne", "fetchAll", etc...)
* @param filter An optional filter to be applied on DB result
* @return Connect middleware
*/
exports.dbAction = function (db, action, filter) {
// Default filter = identity
filter = filter || function (v) { return v; };
return function (req, res, next) {
var params = [];
// Parameters depend of DB action
switch (action) {
case 'save': params.push(req.body); break;
case 'fetchOne':
case 'deleteOne': params.push(req.param('id')); break;
}
// Last parameter is the standard response
params.push(function (err, result) {
err ? next(err) : res.respond(filter(result));
});
// Execute DB action
db[action].apply(db, params);
}
}
{
"name": "application-name"
, "version": "0.0.1"
, "private": true
, "dependencies": {
"express": "2.4.4"
, "ejs": ">= 0.0.1"
, "redis": "*"
, "contracts": "*"
}
, "devDependencies": {
"api-easy": "*"
, "vows": "*"
}
}
var http = require('http');
/**
* Monkey-patch adding "res.respond(...)"
* Usages:
* - res.respond(content as string or object) → 200 OK, with JSON encoded content
* - res.respond(status as number) → given status, with undefined content
* - res.respond(content, status) → ok, you got it :)
*/
http.ServerResponse.prototype.respond = function (content, status) {
if ('undefined' == typeof status) { // only one parameter found
if ('number' == typeof content || !isNaN(parseInt(content))) { // usage "respond(status)"
status = parseInt(content);
content = undefined;
} else { // usage "respond(content)"
status = 200;
}
}
if (status != 200) { // error
content = {
"code": status,
"status": http.STATUS_CODES[status],
"message": content && content.toString() || null
};
}
if ('object' != typeof content) { // wrap content if necessary
content = {"result":content};
}
// respond with JSON data
this.send(JSON.stringify(content)+"\n", status);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment