From 07a79f2bcf7ce39048f4314a2996e95dd482243b Mon Sep 17 00:00:00 2001 From: Kilian Ciuffolo Date: Sat, 17 Sep 2011 12:43:29 +0200 Subject: [PATCH] init import --- .gitignore | 1 + LICENSE | 22 +++++ README.md | 121 +++++++++++++++++++++++++++ lib/apimodule.js | 44 ++++++++++ lib/apiserver.js | 213 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 6 files changed, 405 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lib/apimodule.js create mode 100644 lib/apiserver.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f3d4a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bump.js \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97f075d --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2011 Kilian Ciuffolo, me@nailik.org + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2410840 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# node-apiserver ![project status](http://dl.dropbox.com/u/2208502/maintained.png) + +A ready to go modular http API Server. + +#transports + +- JSON +- JSONP, +- JSONP+iFrame _(you can make cross-domain POST inside the same parent domain: 's1.example.com, s2.example.com, ...')_ + +## Dependencies + +- nodejs v0.4.11+ +- http _native module_ +- querystring _native module_ +- node-bufferjoiner [repository](https://github.com/kilianc/node-bufferjoiner) + +## Installation and first run + + $ git clone git://github.com/kilianc/node-apiserver.git + $ cd node-apiserver + $ node example.js + +## Usage + + var util = require('util'), + querystring = require('querystring'), + ApiServer = require('node-apiserver'), + ApiModule = require('node-apiserver').ApiModule; + + ... + + var UserModule = function(userCollection, myotherObj, myCustomConfig) { + ... + this.userCollection = userCollection; + ApiModule.call(this); + }; + + util.inherits(UserModule, ApiModule); + + // this is a private method, it means that you must send a right Authorization header within your request. + // your method can be reached to http://yourserver.com/user/my_first_camel_case_method + UserModule.prototype.myFirstCamelCaseMethodPrivate = function(request, response) { + + var self = this; + var postData = this.parsePost(request); //inherited from ApiModule. + + if(!/^([a-z0-9_\.\-])+\@(([a-z0-9\-])+\.)+([a-z0-9]{2,4})+$/.test(postData.email)){ + self.emit('responseReady', request, response, 200, null, { success: false, reason: 'email not valid' }); + return this; + } + + this.userCollection.insert({ email: postData.email }, { safe: true }, function(err, user) { + + if(err){ + self.emit('responseReady', request, response, 200, 'this is the http status message', { success: false, reason: 'email not unique' }); + return this; + } + + self.emit('responseReady', request, response, 200, 'this is the http status message', { email: postData.email, success: true }); + }); + }; + + // this is a public method + // the server will check for the [Private|Public] word at the end of the method name. + // your method can be reached to http://yourserver.com/user/my_second_camel_case_method + UserModule.prototype.mySecondCamelCaseMethodPublic = function(request, response) { + + var self = this; + var postData = this.parsePost(request); + + if(!/^([a-z0-9_\.\-])+\@(([a-z0-9\-])+\.)+([a-z0-9]{2,4})+$/.test(postData.email)){ + self.emit('responseReady', request, response, 200, null, { success: false, reason: 'email not valid' }); + return this; + } + + this.userCollection.insert({ email: postData.email }, { safe: true }, function(err, user) { + + if(err){ + self.emit('responseReady', request, response, 200, 'this is the http status message', { success: false, reason: 'email not unique' }); + return this; + } + + self.emit('responseReady', request, response, 200, 'this is the http status message', { email: postData.email, success: true }); + }); + }; + + ... + + var apiServer = new ApiServer('mydomain.com', standardHeaders, "username:password"); + apiServer.addModule('user', new UserModule(new mongodb.Collection(mongodbClient, 'users'), { foo: 'bar' }, { cool: 'sure' })); + apiServer.listen(80); + + ... + +## License + +_This software is released under the MIT license cited below_. + + Copyright (c) 2010 Kilian Ciuffolo, me@nailik.org. All Rights Reserved. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the 'Software'), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/lib/apimodule.js b/lib/apimodule.js new file mode 100644 index 0000000..82c6d44 --- /dev/null +++ b/lib/apimodule.js @@ -0,0 +1,44 @@ +var util = require('util'), + events = require('events'), + querystring = require('querystring'); + +var ApiModule = function(mongodbClient, collections) { + + this.mongodbClient = mongodbClient; + this.collections = collections; + + events.EventEmitter.call(this); +}; + +util.inherits(ApiModule, events.EventEmitter); + +ApiModule.prototype.parseAuthorization = function(request, response){ + + var authorization; + + if(!request.headers.authorization) { + this.emit('responseReady', request, response, 401, 'Authorization Required', { error: { type: 'ApiException', message: 'missing authorization' } }, { 'www-authenticate': 'Basic realm=\'Please Authenticate\''}); + return false; + } + + authorization = new Buffer(request.headers.authorization.replace('Basic ', ''), 'base64').toString('utf8'); + authorization = authorization.split(':'); + + return { email: authorization[0], password: authorization[1] }; +}; + +ApiModule.prototype.parsePost = function(request){ + + var postData; + + try{ + postData = querystring.parse(request.postData.toString('utf8')); + } + catch(e){ + postData = {}; + } + + return postData; +}; + +module.exports = ApiModule; \ No newline at end of file diff --git a/lib/apiserver.js b/lib/apiserver.js new file mode 100644 index 0000000..e2ea97c --- /dev/null +++ b/lib/apiserver.js @@ -0,0 +1,213 @@ +var http = require('http'), + url = require('url'), + querystring = require('querystring'), + BufferJoiner = require('node-bufferjoiner'), + ApiModule = require('./apimodule'); + +var ApiServer = function(domain, standardHeaders, credentials) { + + this.credentials = 'Basic ' + new Buffer(credentials, 'utf8').toString('base64'); + + this.iframeHtmlTemplate = ""; + this.activeApiModules = {}; + this.standardHeaders = standardHeaders || { + 'cache-control': 'max-age=0, no-cache, no-store, must-revalidate', + 'expires': 0, + 'pragma': 'no-cache' + }; + + this.activeApiModules = {}; + this.server = http.createServer(this.onRequest.bind(this)); +}; + +ApiServer.VERSION = 'v0.0.1'; +ApiServer.ApiModule = ApiModule; + +ApiServer.prototype.addModule = function(moduleName, apiModule){ + + apiModule.on('responseReady', this.serveResponse.bind(this, apiModule)); + apiModule.on('responseStreamStart', this.streamResponseStart.bind(this, apiModule)); + apiModule.on('responseStreamData', this.streamResponseData.bind(this, apiModule)); + apiModule.on('responseStreamEnd', this.streamResponseEnd.bind(this, apiModule)); + + this.activeApiModules[moduleName] = apiModule; +}; + +ApiServer.prototype.listen = function(port){ + this.server.listen(port); +}; + +ApiServer.prototype.onRequest = function(request, response) { + + var self = this; + var parsedUrl = url.parse(request.url, true); + + request.pathname = parsedUrl.pathname.split('/'); + request.querystring = parsedUrl.query; + request.postData = new BufferJoiner(); + + request.apiModule = request.pathname[1] || ''; + request.apiAction = request.pathname[2] || ''; + + request.apiModule = request.apiModule.replace(/(\_[a-z])/g, function(match){ return match.toUpperCase()[1]; }); + request.apiAction = request.apiAction.replace(/(\_[a-z])/g, function(match){ return match.toUpperCase()[1]; }); + + if(!this.checkApiExists(request)) { + this.onApiNotFound(request, response); + return; + } + + if(!this.authorizedApi(request, response)) + return; + + request.connection.setTimeout(15000); + + if(request.method == 'GET'){ + request.postData = request.postData.join(); + this.onRequestComplete(request, response); + } + else { + request.on('data', function(chunk){ + request.postData.add(chunk); + }); + + request.once('end', function(){ + request.postData = request.postData.join(); + self.onRequestComplete(request, response); + }); + } +}; + +ApiServer.prototype.checkApiExists = function(request) { + + return (this.activeApiModules[request.apiModule] && (this.activeApiModules[request.apiModule][request.apiAction + 'Public'] || this.activeApiModules[request.apiModule][request.apiAction + 'Private'])); +}; + +ApiServer.prototype.onApiNotFound = function(request, response) { + + this.serveResponse(this, request, response, 500, 'errore', { + error: { + type: 'ApiException', + message: request.url + ' api not found' + } + }); +} + +ApiServer.prototype.authorizedApi = function(request, response) { + + if(!this.activeApiModules[request.apiModule][request.apiAction + 'Private']) { + + request.apiAction += 'Public'; + return true; + } + + if(!request.headers.authorization || request.headers.authorization != this.credentials) { + + response.writeHead(401, 'Authorization Required', { 'www-authenticate': 'Basic realm=\'Please Authenticate\''}); + response.end(); + + return false; + } + + request.apiAction += 'Private' + + return true; +}; + +ApiServer.prototype.onRequestComplete = function(request, response) { + + try { + this.activeApiModules[request.apiModule][request.apiAction](request, response); + } catch (err) { + + throw err; + + this.serveResponse(this, request, response, 500, 'errore', { + error: { + type: 'ApiException', + message: 'something went wrong: ' + request.url + } + }); + } +}; + +ApiServer.prototype.serveResponse = function(sender, request, response, httpStatusCode, httpStatusMessage, responseContent, responseHeaders) { + + if(request.querystring.callback) { + + if(request.method == 'POST') { + + contentType = 'text/html'; + responseContent = this.iframeHtmlTemplate.replace('[CALLBACK]', request.querystring.callback).replace('[DATA]', JSON.stringify(responseContent)); + } + else { + contentType = 'text/javascript'; + responseContent = request.querystring.callback + '(' + JSON.stringify(responseContent, null, ' ') + ');'; + } + } + else { + contentType = 'text/javascript'; + responseContent = JSON.stringify(responseContent, null, ' '); + } + + responseHeaders = responseHeaders || {}; + + for(var headerKey in this.standardHeaders) + (responseHeaders[headerKey] === undefined) && (responseHeaders[headerKey] = this.standardHeaders[headerKey]); + + responseHeaders['content-type'] = contentType + '; charset=UTF-8'; + responseHeaders['content-length'] = responseContent.length; + + response.writeHead(httpStatusCode, httpStatusMessage ? httpStatusMessage : '', responseHeaders); + response.end(responseContent, 'utf8'); +}; + +ApiServer.prototype.streamResponseStart = function(sender, request, response, httpStatusCode, httpStatusMessage, responseHeaders) { + + if(request.querystring.callback){ + + if(request.method == 'POST'){ + + contentType = 'text/html'; + + var parts = this.iframeHtmlTemplate.replace('[CALLBACK]', request.querystring.callback).split('[DATA]'); + + response.streamStart = parts[0] + '['; + response.streamEnd = ']' + parts[1]; + + } else { + + contentType = 'text/javascript'; + response.streamStart = request.querystring.callback + '(['; + response.streamEnd = ']);'; + } + + } else { + + contentType = 'text/javascript'; + response.streamStart = '['; + response.streamEnd = ']'; + } + + responseHeaders = responseHeaders || {}; + + for(var headerKey in this.standardHeaders) + (responseHeaders[headerKey] === undefined) && (responseHeaders[headerKey] = this.standardHeaders[headerKey]); + + responseHeaders['content-type'] = contentType + '; charset=UTF-8'; + + response.writeHead(httpStatusCode, httpStatusMessage ? httpStatusMessage : '', responseHeaders); + response.write(response.streamStart, 'utf8'); + + delete response.streamStart; +}; + +ApiServer.prototype.streamResponseData = function(sender, request, response, responseChunk, isLast) { + response.write(JSON.stringify(responseChunk, null, ' ') + (isLast ? '' : ',\n'), 'utf8'); +}; + +ApiServer.prototype.streamResponseEnd = function(sender, request, response){ + response.end(response.streamEnd, 'utf8'); +}; + +module.exports = ApiServer; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b272f0 --- /dev/null +++ b/package.json @@ -0,0 +1,4 @@ +{ + "name" : "apiserver", + "main" : "./lib/apiserver.js" +} \ No newline at end of file