├── test ├── all.js ├── test_helper.js └── restler.js ├── index.js ├── .gitignore ├── package.json ├── bin └── restler ├── MIT-LICENSE ├── README.md └── lib ├── multipartform.js └── restler.js /test/all.js: -------------------------------------------------------------------------------- 1 | require('./restler'); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/restler'); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .[Dd][Ss]_store 3 | node_modules 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "restler", 3 | "version": "0.2.4", 4 | "description": "An HTTP client library for node.js", 5 | "contributors": [{ "name": "Dan Webb", "email": "dan@danwebb.net" }], 6 | "homepage": "https://github.com/danwrong/restler", 7 | "directories" : { "lib" : "./lib" }, 8 | "main" : "./lib/restler", 9 | "engines": { "node": ">= 0.3.7" } 10 | } 11 | -------------------------------------------------------------------------------- /bin/restler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require.paths.push(process.cwd()); 4 | 5 | var sys = require('util'), 6 | rest = require('../lib/restler'), 7 | repl = require('repl'); 8 | 9 | var replServer = repl.start(); 10 | 11 | var exportMethods = { 12 | sys: sys, 13 | rest: rest 14 | } 15 | 16 | Object.keys(exportMethods).forEach(function(exportMethod) { 17 | replServer.context[exportMethod] = exportMethods[exportMethod]; 18 | }); 19 | 20 | rest.get('http://twaud.io/api/v1/7.json').on('complete', function(data, response) { 21 | console.log(response.headers); 22 | replServer.context.data = data; 23 | }); -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Dan Webb 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /test/test_helper.js: -------------------------------------------------------------------------------- 1 | var http = require("http"), 2 | sys = require('util'), 3 | test = require('assert'); 4 | 5 | exports.echoServer = function() { 6 | var server = http.createServer(function(request, response) { 7 | var echo = [request.method, request.url, "HTTP/" + 8 | request.httpVersion].join(' ') + "\r\n"; 9 | for (var header in request.headers) { 10 | echo += header + ": " + request.headers[header] + "\r\n"; 11 | } 12 | echo += '\r\n'; 13 | 14 | request.addListener('data', function(chunk) { 15 | echo += chunk.toString('binary'); 16 | }); 17 | request.addListener('end', function() { 18 | 19 | var requestedCode = request.headers['x-give-me-status']; 20 | 21 | response.writeHead(requestedCode || 200, { 22 | 'Content-Type': 'text/plain', 23 | 'Content-Length': echo.length 24 | }); 25 | 26 | response.write(echo); 27 | response.end(); 28 | server.close(); 29 | }); 30 | }); 31 | 32 | var port = exports.port++; 33 | server.listen(port, "localhost"); 34 | return ["http://localhost:" + port, server]; 35 | } 36 | 37 | exports.dataServer = function() { 38 | var json = "{ \"ok\": true }"; 39 | var xml = "true"; 40 | var yaml = "ok: true"; 41 | 42 | var server = http.createServer(function(request, response) { 43 | response.writeHead(200, { 'Content-Type': request.headers['accepts'], 'test': 'thing' }); 44 | 45 | if (request.headers['accepts'] == 'application/json') { 46 | response.write(json); 47 | } 48 | 49 | if (request.headers['accepts'] == 'application/xml') { 50 | response.write(xml); 51 | } 52 | 53 | if (request.headers['accepts'] == 'application/yaml') { 54 | response.write(yaml); 55 | } 56 | 57 | response.end(); 58 | server.close(); 59 | }); 60 | 61 | var port = exports.port++; 62 | server.listen(port, "localhost"); 63 | return ["http://localhost:" + port, server]; 64 | } 65 | 66 | exports.redirectServer = function() { 67 | var port = exports.port++; 68 | 69 | var server = http.createServer(function(request, response) { 70 | if (request.url == '/redirected') { 71 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 72 | response.write('Hell Yeah!'); 73 | response.end(); 74 | server.close(); 75 | } else { 76 | response.writeHead(301, { 'Location': 'http://localhost:' + port + '/redirected' }); 77 | response.write('Redirecting...'); 78 | response.end(); 79 | } 80 | 81 | }); 82 | 83 | server.listen(port, "localhost"); 84 | return ["http://localhost:" + port, server]; 85 | } 86 | 87 | exports.contentLengthServer = function (){ 88 | var port = exports.port++; 89 | 90 | var server = http.createServer(function(request, response){ 91 | response.writeHead(200, { 'Content-Type': 'text/plain' }); 92 | if('content-length' in request.headers){ 93 | response.write(request.headers['content-length']); 94 | } else { 95 | response.write('content-length isnot set'); 96 | } 97 | 98 | response.end(); 99 | server.close(); 100 | }); 101 | 102 | server.listen(port, 'localhost'); 103 | return ['http://localhost:' + port, server]; 104 | }; 105 | 106 | exports.port = 7000; 107 | 108 | exports.testCase = function(caseName, serverFunc, tests) { 109 | var testCount = 0, passes = 0, fails = 0; 110 | 111 | function wrapAssertions(name) { 112 | var assertions = {}; 113 | 114 | [ 115 | 'ok', 116 | 'equal', 117 | 'notEqual', 118 | 'deepEqual', 119 | 'notDeepEqual', 120 | 'strictEqual', 121 | 'notStrictEqual', 122 | 'throws', 123 | 'doesNotThrow', 124 | 'ifError' 125 | ].forEach(function(assert) { 126 | assertions[assert] = function() { 127 | testCount++; 128 | try { 129 | test[assert].apply(this, arguments); 130 | passes++; 131 | } catch(e) { 132 | sys.puts(name + ': ' + e); 133 | fails++; 134 | } 135 | } 136 | }); 137 | 138 | return assertions; 139 | } 140 | 141 | if (typeof serverFunc != 'function') { 142 | tests = serverFunc; 143 | serverFunc = null; 144 | } 145 | 146 | for (var name in tests) { 147 | if (name.match(/^test/)) { 148 | if (typeof serverFunc == 'function') { 149 | var res = serverFunc(), host = res[0], 150 | server = res[1]; 151 | tests[name](host, wrapAssertions(name)); 152 | } else { 153 | tests[name](wrapAssertions(name)); 154 | } 155 | } 156 | } 157 | 158 | process.addListener('exit', function() { 159 | var passFail = (testCount == passes) ? ' \033[0;32mPASS\033[1;37m' : ' \033[0;31mFAIL\033[1;37m'; 160 | sys.puts(caseName + " - Assertions: " + testCount + " Passed: " + passes + " Failed: " + fails + passFail); 161 | }); 162 | } 163 | 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Restler 0.2.4 2 | =========== 3 | 4 | (C) Dan Webb (dan@danwebb.net/@danwrong) 2011, Licensed under the MIT-LICENSE 5 | 6 | An HTTP client library for node.js (0.3 and up). Hides most of the complexity of creating and using http.Client. Very early days yet. 7 | 8 | See [Version History](https://github.com/danwrong/restler/wiki/Version-History) for changes 9 | 10 | 11 | Features 12 | -------- 13 | 14 | * Easy interface for common operations via http.request 15 | * Automatic serialization of post data 16 | * Automatic serialization of query string data 17 | * Automatic deserialization of XML, JSON and YAML responses to JavaScript objects (if you have js-yaml and/or xml2js in the require path) 18 | * Provide your own deserialization functions for other datatypes 19 | * Automatic following of redirects 20 | * Send files with multipart requests 21 | * Transparently handle SSL (just specify https in the URL) 22 | * Deals with basic auth for you, just provide username and password options 23 | * Simple service wrapper that allows you to easily put together REST API libraries 24 | * Transparently handle content-encoded responses (gzip, deflate) 25 | 26 | 27 | API 28 | --- 29 | 30 | ### request(url, options) 31 | 32 | Basic method to make a request of any type. The function returns a RestRequest object 33 | that emits events: 34 | 35 | * _complete_ emitted when the request has finished whether it was successful or not. Gets passed the response data and the response as arguments. 36 | * _success_ emitted when the request was successful. Gets passed the response data and the response as arguments. 37 | * _error_ emitted when the request was unsuccessful. Gets passed the response data and the response as arguments. 38 | * _2XX, 3XX, 4XX, 5XX etc_ emitted for all requests with response codes in the range. Eg. 2XX emitted for 200, 201, 203 39 | * _actual response code_ there is an event emitted for every single response code. eg. 404, 201, etc. 40 | 41 | ### get(url, options) 42 | 43 | Create a GET request. 44 | 45 | ### post(url, options) 46 | 47 | Create a POST request. 48 | 49 | ### put(url, options) 50 | 51 | Create a PUT request. 52 | 53 | ### del(url, options) 54 | 55 | Create a DELETE request. 56 | 57 | ### response parsers 58 | 59 | You can give any of these to the parsers option to specify how the response data is deserialized. 60 | 61 | #### parsers.auto 62 | 63 | Checks the content-type and then uses parsers.xml, parsers.json or parsers.yaml. 64 | If the content type isn't recognised it just returns the data untouched. 65 | 66 | #### parsers.json, parsers.xml, parsers.yaml 67 | 68 | All of these attempt to turn the response into a JavaScript object. In order to use the YAML and XML parsers you must have yaml and/or xml2js installed. 69 | 70 | ### options hash 71 | 72 | * _method_ Request method, can be get, post, put, del 73 | * _query_ Query string variables as a javascript object, will override the querystring in the URL 74 | * _data_ The data to be added to the body of the request. Can be a string or any object. 75 | Note that if you want your request body to be JSON with the Content-Type `application/json`, you need to 76 | JSON.stringify your object first. Otherwise, it will be sent as `application/x-www-form-urlencoded` and encoded accordingly. 77 | * _parser_ A function that will be called on the returned data. try parsers.auto, parsers.json etc 78 | * _encoding_ The encoding of the request body. defaults to utf8 79 | * _decoding_ The encoding of the response body. For a list of supported values see [Buffers](http://nodejs.org/docs/latest/api/buffers.html#buffers). Additionally accepts `"buffer"` - returns response as `Buffer`. Defaults to `utf8`. 80 | * _headers_ a hash of HTTP headers to be sent 81 | * _username_ Basic auth username 82 | * _password_ Basic auth password 83 | * _multipart_ If set the data passed will be formated as multipart/form-encoded. See multipart example below. 84 | * _client_ A http.Client instance if you want to reuse or implement some kind of connection pooling. 85 | * _followRedirects_ Does what it says on the tin. 86 | 87 | 88 | Example usage 89 | ------------- 90 | 91 | ```javascript 92 | var sys = require('util'), 93 | rest = require('./restler'); 94 | 95 | rest.get('http://google.com').on('complete', function(data) { 96 | sys.puts(data); 97 | }); 98 | 99 | rest.get('http://twaud.io/api/v1/users/danwrong.json').on('complete', function(data) { 100 | sys.puts(data[0].message); // auto convert to object 101 | }); 102 | 103 | rest.get('http://twaud.io/api/v1/users/danwrong.xml').on('complete', function(data) { 104 | sys.puts(data[0].sounds[0].sound[0].message); // auto convert to object 105 | }); 106 | 107 | rest.post('http://user:pass@service.com/action', { 108 | data: { id: 334 }, 109 | }).on('complete', function(data, response) { 110 | if (response.statusCode == 201) { 111 | // you can get at the raw response like this... 112 | } 113 | }); 114 | 115 | // multipart request sending a file and using https 116 | rest.post('https://twaud.io/api/v1/upload.json', { 117 | multipart: true, 118 | username: 'danwrong', 119 | password: 'wouldntyouliketoknow', 120 | data: { 121 | 'sound[message]': 'hello from restler!', 122 | 'sound[file]': rest.file('doug-e-fresh_the-show.mp3', null, null, null, 'audio/mpeg') 123 | } 124 | }).on('complete', function(data) { 125 | sys.puts(data.audio_url); 126 | }); 127 | 128 | // create a service constructor for very easy API wrappers a la HTTParty... 129 | Twitter = rest.service(function(u, p) { 130 | this.defaults.username = u; 131 | this.defaults.password = p; 132 | }, { 133 | baseURL: 'http://twitter.com' 134 | }, { 135 | update: function(message) { 136 | return this.post('/statuses/update.json', { data: { status: message } }); 137 | } 138 | }); 139 | 140 | var client = new Twitter('danwrong', 'password'); 141 | client.update('Tweeting using a Restler service thingy').on('complete', function(data) { 142 | sys.p(data); 143 | }); 144 | 145 | // the JSON post 146 | rest.post('http://example.com/action', { 147 | data: JSON.stringify({ id: 334 }), 148 | }).on('complete', function(data, response) { 149 | // you can get at the raw response like this... 150 | }); 151 | ``` 152 | 153 | Running the tests 154 | ----------------- 155 | 156 | ```javascript 157 | node test/restler.js 158 | ``` 159 | 160 | TODO 161 | ---- 162 | * Deal with no utf-8 response bodies 163 | * What do you need? Let me know or fork. 164 | -------------------------------------------------------------------------------- /lib/multipartform.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var sys = require('util') 3 | exports.defaultBoundary = '48940923NODERESLTER3890457293'; 4 | 5 | 6 | // This little object allows us hijack the write method via duck-typing 7 | // and write to strings or regular streams that support the write method. 8 | function Stream(stream) { 9 | //If the user pases a string for stream,we initalize one to write to 10 | if (this._isString(stream)) { 11 | this.string = ""; 12 | } 13 | this.stream = stream; 14 | 15 | } 16 | 17 | Stream.prototype = { 18 | //write to an internal String or to the Stream 19 | write: function(data) { 20 | if (this.string != undefined) { 21 | this.string += data; 22 | } else { 23 | this.stream.write(data, "binary"); 24 | } 25 | }, 26 | 27 | //stolen from underscore.js 28 | _isString: function(obj) { 29 | return !!(obj === '' || (obj && obj.charCodeAt && obj.substr)); 30 | } 31 | } 32 | 33 | function File(path, filename, fileSize, encoding, contentType) { 34 | this.path = path; 35 | this.filename = filename || this._basename(path); 36 | this.fileSize = fileSize; 37 | this.encoding = encoding || "binary"; 38 | this.contentType = contentType || 'application/octet-stream'; 39 | } 40 | 41 | File.prototype = { 42 | _basename: function(path) { 43 | var parts = path.split(/\/|\\/); 44 | return parts[parts.length - 1]; 45 | } 46 | }; 47 | 48 | function Data(filename, contentType, data) { 49 | this.filename = filename; 50 | this.contentType = contentType || 'application/octet-stream'; 51 | this.data = data; 52 | } 53 | 54 | function Part(name, value, boundary) { 55 | this.name = name; 56 | this.value = value; 57 | this.boundary = boundary; 58 | } 59 | 60 | 61 | Part.prototype = { 62 | 63 | //returns the Content-Disposition header 64 | header: function() { 65 | var header; 66 | 67 | if (this.value.data) { 68 | header = "Content-Disposition: form-data; name=\"" + this.name + 69 | "\"; filename=\"" + this.value.filename + "\"\r\n" + 70 | "Content-Type: " + this.value.contentType; 71 | } if (this.value instanceof File) { 72 | header = "Content-Disposition: form-data; name=\"" + this.name + 73 | "\"; filename=\"" + this.value.filename + "\"\r\n" + 74 | "Content-Length: " + this.value.fileSize + "\r\n" + 75 | "Content-Type: " + this.value.contentType; 76 | } else { 77 | header = "Content-Disposition: form-data; name=\"" + this.name + "\""; 78 | } 79 | 80 | return "--" + this.boundary + "\r\n" + header + "\r\n\r\n"; 81 | }, 82 | 83 | //calculates the size of the Part 84 | sizeOf: function() { 85 | var valueSize; 86 | if (this.value instanceof File) { 87 | valueSize = this.value.fileSize; 88 | } else if (this.value.data) { 89 | valueSize = this.value.data.length; 90 | } else { 91 | valueSize = this.value.length; 92 | } 93 | return valueSize + this.header().length + 2; 94 | }, 95 | 96 | // Writes the Part out to a writable stream that supports the write(data) method 97 | // You can also pass in a String and a String will be returned to the callback 98 | // with the whole Part 99 | // Calls the callback when complete 100 | write: function(stream, callback) { 101 | 102 | var self = this; 103 | 104 | //first write the Content-Disposition 105 | stream.write(this.header()); 106 | 107 | //Now write out the body of the Part 108 | if (this.value instanceof File) { 109 | fs.open(this.value.path, "r", 0666, function (err, fd) { 110 | if (err) throw err; 111 | 112 | var position = 0; 113 | 114 | (function reader () { 115 | fs.read(fd, 1024 * 4, position, "binary", function (er, chunk) { 116 | if (er) callback(err); 117 | stream.write(chunk); 118 | position += 1024 * 4; 119 | if (chunk) reader(); 120 | else { 121 | stream.write("\r\n") 122 | callback(); 123 | fs.close(fd); 124 | } 125 | }); 126 | })(); // reader() 127 | }); 128 | } else { 129 | stream.write(this.value + "\r\n"); 130 | callback(); 131 | } 132 | } 133 | } 134 | 135 | //Renamed to MultiPartRequest from Request 136 | function MultiPartRequest(data, boundary) { 137 | this.encoding = 'binary'; 138 | this.boundary = boundary || exports.defaultBoundary; 139 | this.data = data; 140 | this.partNames = this._partNames(); 141 | } 142 | 143 | MultiPartRequest.prototype = { 144 | _partNames: function() { 145 | var partNames = []; 146 | for (var name in this.data) { 147 | partNames.push(name) 148 | } 149 | return partNames; 150 | }, 151 | 152 | write: function(stream, callback) { 153 | var partCount = 0, self = this; 154 | 155 | // wrap the stream in our own Stream object 156 | // See the Stream function above for the benefits of this 157 | var stream = new Stream(stream); 158 | 159 | // Let each part write itself out to the stream 160 | (function writePart() { 161 | var partName = self.partNames[partCount]; 162 | var part = new Part(partName, self.data[partName], self.boundary); 163 | part.write(stream, function (err) { 164 | if (err) { 165 | callback(err); 166 | return; 167 | } 168 | partCount += 1; 169 | if (partCount < self.partNames.length) 170 | writePart(); 171 | else { 172 | stream.write('--' + self.boundary + '--' + "\r\n"); 173 | 174 | if (callback) callback(stream.string || ""); 175 | } 176 | }); 177 | })(); 178 | } 179 | } 180 | 181 | var exportMethods = { 182 | file: function(path, filename, fileSize, encoding, contentType) { 183 | return new File(path, filename, fileSize, encoding, contentType) 184 | }, 185 | data: function(filename, contentType, data) { 186 | return new Data(filename, contentType, data); 187 | }, 188 | sizeOf: function(parts, boundary) { 189 | var totalSize = 0; 190 | boundary = boundary || exports.defaultBoundary; 191 | for (var name in parts) totalSize += new Part(name, parts[name], boundary).sizeOf(); 192 | return totalSize + boundary.length + 6; 193 | }, 194 | write: function(stream, data, callback, boundary) { 195 | var r = new MultiPartRequest(data, boundary); 196 | r.write(stream, callback); 197 | return r; 198 | } 199 | } 200 | 201 | Object.keys(exportMethods).forEach(function(exportMethod) { 202 | exports[exportMethod] = exportMethods[exportMethod] 203 | }) 204 | -------------------------------------------------------------------------------- /test/restler.js: -------------------------------------------------------------------------------- 1 | var helper = require('./test_helper'), 2 | rest = require('../lib/restler'), 3 | sys = require('util'); 4 | 5 | helper.testCase("Basic Tests", helper.echoServer, { 6 | testRequestShouldTakePath: function(host, test) { 7 | rest.get(host + '/thing').on('complete', function(data) { 8 | test.ok(/^GET \/thing/.test(data), 'should hit /thing'); 9 | }); 10 | }, 11 | testRequestShouldWorkWithNoPath: function(host, test) { 12 | rest.get(host).on('complete', function(data) { 13 | test.ok(/^GET \//.test(data), 'should hit /'); 14 | }); 15 | }, 16 | testRequestShouldWorkPreserveQueryStringInURL: function(host, test) { 17 | rest.get(host + '/thing?boo=yah').on('complete', function(data) { 18 | test.ok(/^GET \/thing\?boo\=yah/.test(data), 'should hit /thing?boo=yah'); 19 | }); 20 | }, 21 | testRequestShouldBeAbleToGET: function(host, test) { 22 | rest.get(host).on('complete', function(data) { 23 | test.ok(/^GET/.test(data), 'should be GET'); 24 | }); 25 | }, 26 | testRequestShouldBeAbleToPUT: function(host, test) { 27 | rest.put(host).on('complete', function(data) { 28 | test.ok(/^PUT/.test(data), 'should be PUT'); 29 | }); 30 | }, 31 | testRequestShouldBeAbleToPOST: function(host, test) { 32 | rest.post(host).on('complete', function(data) { 33 | test.ok(/^POST/.test(data), 'should be POST'); 34 | }); 35 | }, 36 | testRequestShouldBeAbleToDELETE: function(host, test) { 37 | rest.del(host).on('complete', function(data) { 38 | test.ok(/^DELETE/.test(data), 'should be DELETE'); 39 | }); 40 | }, 41 | testRequestShouldSerializeQuery: function(host, test) { 42 | rest.get(host, { query: { q: 'balls' } }).on('complete', function(data) { 43 | test.ok(/^GET \/\?q\=balls/.test(data), 'should hit /?q=balls'); 44 | }); 45 | }, 46 | testRequestShouldPostBody: function(host, test) { 47 | rest.post(host, { data: "balls" }).on('complete', function(data) { 48 | test.ok(/\r\n\r\nballs/.test(data), 'should have balls in the body') 49 | }); 50 | }, 51 | testRequestShouldSerializePostBody: function(host, test) { 52 | rest.post(host, { data: { q: 'balls' } }).on('complete', function(data) { 53 | test.ok(/content-type\: application\/x-www-form-urlencoded/.test(data), 54 | 'should set content-type'); 55 | test.ok(/content-length\: 7/.test(data), 'should set content-length'); 56 | test.ok(/\r\n\r\nq=balls/.test(data), 'should have balls in the body') 57 | }); 58 | }, 59 | testRequestShouldSendHeaders: function(host, test) { 60 | rest.get(host, { 61 | headers: { 'Content-Type': 'application/json' } 62 | }).on('complete', function(data) { 63 | test.ok(/content\-type\: application\/json/.test(data), 'should content type header') 64 | }); 65 | }, 66 | testRequestShouldSendBasicAuth: function(host, test) { 67 | rest.post(host, { username: 'danwrong', password: 'flange' }).on('complete', function(data) { 68 | test.ok(/authorization\: Basic ZGFud3Jvbmc6Zmxhbmdl/.test(data), 'should have auth header') 69 | }); 70 | }, 71 | testRequestShouldSendBasicAuthIfInURL: function(host, test) { 72 | var port = host.match(/\:(\d+)/)[1]; 73 | host = "http://danwrong:flange@localhost:" + port; 74 | rest.post(host).on('complete', function(data) { 75 | test.ok(/authorization\: Basic ZGFud3Jvbmc6Zmxhbmdl/.test(data), 'should have auth header') 76 | }); 77 | }, 78 | testRequestShouldFire2XXAnd200Events: function(host, test) { 79 | var count = 0; 80 | 81 | rest.get(host).on('2XX', function() { 82 | count++; 83 | }).on('200', function() { 84 | count++; 85 | }).on('complete', function() { 86 | test.equal(2, count); 87 | }); 88 | }, 89 | testRequestShouldFireError4XXand404EventsFor404: function(host, test) { 90 | var count = 0; 91 | 92 | rest.get(host, { headers: { 'X-Give-Me-Status': 404 }}).on('error', function() { 93 | count++; 94 | }).on('4XX', function() { 95 | count++; 96 | }).on('404', function() { 97 | count++; 98 | }).on('complete', function() { 99 | test.equal(3, count); 100 | }); 101 | } 102 | }); 103 | 104 | helper.testCase('Multipart Tests', helper.echoServer, { 105 | testMultipartRequestWithSimpleVars: function(host, test) { 106 | rest.post(host, { 107 | data: { a: 1, b: 'thing' }, 108 | multipart: true 109 | }).on('complete', function(data) { 110 | test.ok(/content-type\: multipart\/form-data/.test(data), 'should set content type') 111 | test.ok(/name="a"(\s)+1/.test(data), 'should send a=1'); 112 | test.ok(/name="b"(\s)+thing/.test(data), 'should send b=thing'); 113 | }); 114 | } 115 | }); 116 | 117 | 118 | helper.testCase("Deserialization Tests", helper.dataServer, { 119 | testAutoSerializerShouldParseJSON: function(host, test) { 120 | rest.get(host, { 121 | headers: { 'Accepts': 'application/json' } 122 | }).on('complete', function(data) { 123 | test.equal(true, data.ok, "returned " + sys.inspect(data)); 124 | }); 125 | }, 126 | testAutoSerializerShouldParseXML: function(host, test) { 127 | rest.get(host, { 128 | headers: { 'Accepts': 'application/xml' } 129 | }).on('complete', function(data) { 130 | test.equal("true", data.ok, "returned " + sys.inspect(data)); 131 | }); 132 | }, 133 | testAutoSerializerShouldParseYAML: function(host, test) { 134 | rest.get(host, { 135 | headers: { 'Accepts': 'application/yaml' } 136 | }).on('complete', function(data) { 137 | test.equal(true, data.ok, "returned " + sys.inspect(data)); 138 | }); 139 | } 140 | }); 141 | 142 | helper.testCase('Redirect Tests', helper.redirectServer, { 143 | testShouldAutomaticallyFollowRedirects: function(host, test) { 144 | rest.get(host).on('complete', function(data) { 145 | test.equal('Hell Yeah!', data, "returned " + sys.inspect(data)); 146 | }); 147 | } 148 | }); 149 | 150 | helper.testCase('Content-Length Tests', helper.contentLengthServer, { 151 | testRequestHeaderIncludesContentLengthWithJSONData: function(host, test){ 152 | var jsonString = JSON.stringify({ greeting: 'hello world' }); 153 | rest.post(host, { 154 | data: jsonString 155 | }).on('complete', function(data){ 156 | test.equal(26, data, 'should set content-length'); 157 | }); 158 | }, 159 | testJSONMultibyteContentLength: function (host, test){ 160 | var multibyteData = JSON.stringify({ greeting: 'こんにちは世界' }); 161 | rest.post(host, { 162 | data: multibyteData 163 | }).on('complete', function(data) { 164 | test.notEqual(22, data, 'should unicode string length'); 165 | test.equal(36, data, 'should byte-size content-length'); 166 | }); 167 | } 168 | }); 169 | -------------------------------------------------------------------------------- /lib/restler.js: -------------------------------------------------------------------------------- 1 | var sys = require('util'), 2 | http = require('http'), 3 | https = require('https'), 4 | url = require('url'), 5 | qs = require('querystring'), 6 | multipart = require('./multipartform'), 7 | zlib = require('zlib'); 8 | 9 | function mixin(target, source) { 10 | Object.keys(source).forEach(function(key) { 11 | target[key] = source[key]; 12 | }); 13 | 14 | return target; 15 | } 16 | 17 | function Request(uri, options) { 18 | this.url = url.parse(uri); 19 | this.options = options; 20 | this.headers = { 21 | 'Accept': '*/*', 22 | 'User-Agent': 'Restler for node.js', 23 | 'Host': this.url.host 24 | }; 25 | 26 | mixin(this.headers, options.headers || {}); 27 | 28 | // set port and method defaults 29 | if (!this.url.port) this.url.port = (this.url.protocol == 'https:') ? '443' : '80'; 30 | if (!this.options.method) this.options.method = (this.options.data) ? 'POST' : 'GET'; 31 | if (typeof this.options.followRedirects == 'undefined') this.options.followRedirects = true; 32 | 33 | // stringify query given in options of not given in URL 34 | if (this.options.query && !this.url.query) { 35 | if (typeof this.options.query == 'object') 36 | this.url.query = qs.stringify(this.options.query); 37 | else this.url.query = this.options.query; 38 | } 39 | 40 | this._applyBasicAuth(); 41 | 42 | if (this.options.multipart) { 43 | this.headers['Content-Type'] = 'multipart/form-data; boundary=' + multipart.defaultBoundary; 44 | } else { 45 | if (typeof this.options.data == 'object') { 46 | this.options.data = qs.stringify(this.options.data); 47 | this.headers['Content-Type'] = 'application/x-www-form-urlencoded'; 48 | this.headers['Content-Length'] = this.options.data.length; 49 | } 50 | if(typeof this.options.data == 'string') { 51 | var buffer = new Buffer(this.options.data, this.options.encoding || 'utf8'); 52 | this.options.data = buffer; 53 | this.headers['Content-Length'] = buffer.length; 54 | } 55 | } 56 | 57 | var proto = (this.url.protocol == 'https:') ? https : http; 58 | 59 | this.request = proto.request({ 60 | host: this.url.hostname, 61 | port: this.url.port, 62 | path: this._fullPath(), 63 | method: this.options.method, 64 | headers: this.headers 65 | }); 66 | 67 | this._makeRequest(); 68 | } 69 | 70 | Request.prototype = new process.EventEmitter(); 71 | 72 | mixin(Request.prototype, { 73 | _isRedirect: function(response) { 74 | return ([301, 302, 303].indexOf(response.statusCode) >= 0); 75 | }, 76 | _fullPath: function() { 77 | var path = this.url.pathname || '/'; 78 | if (this.url.hash) path += this.url.hash; 79 | if (this.url.query) path += '?' + this.url.query; 80 | return path; 81 | }, 82 | _applyBasicAuth: function() { 83 | var authParts; 84 | 85 | if (this.url.auth) { 86 | authParts = this.url.auth.split(':'); 87 | this.options.username = authParts[0]; 88 | this.options.password = authParts[1]; 89 | } 90 | 91 | if (this.options.username && this.options.password) { 92 | var b = new Buffer([this.options.username, this.options.password].join(':')); 93 | this.headers['Authorization'] = "Basic " + b.toString('base64'); 94 | } 95 | }, 96 | _responseHandler: function(response) { 97 | var self = this; 98 | 99 | if (this._isRedirect(response) && this.options.followRedirects == true) { 100 | try { 101 | var location = url.resolve(this.url, response.headers['location']); 102 | this.options.originalRequest = this; 103 | 104 | request(location, this.options); 105 | } catch(e) { 106 | self._respond('error', '', 'Failed to follow redirect'); 107 | } 108 | } else { 109 | var body = ''; 110 | 111 | response.setEncoding('binary'); 112 | 113 | response.on('data', function(chunk) { 114 | body += chunk; 115 | }); 116 | 117 | response.on('end', function() { 118 | self._decode(new Buffer(body, 'binary'), response, function(err, body) { 119 | if (err) { 120 | self._respond('error', '', 'Failed to decode response body'); 121 | return; 122 | } 123 | self._encode(body, response, function(body) { 124 | self._fireEvents(body, response); 125 | }); 126 | }); 127 | }); 128 | } 129 | }, 130 | _decode: function(body, response, callback) { 131 | var encoder = response.headers['content-encoding']; 132 | if (encoder in decoders) { 133 | decoders[encoder].call(response, body, callback); 134 | } else { 135 | callback(null, body); 136 | } 137 | }, 138 | _encode: function(body, response, callback) { 139 | var self = this; 140 | if (self.options.decoding == 'buffer') { 141 | callback(body); 142 | } else { 143 | body = body.toString(self.options.decoding); 144 | if (self.options.parser) { 145 | self.options.parser.call(response, body, callback); 146 | } else { 147 | callback(body); 148 | } 149 | } 150 | }, 151 | _respond: function(type, data, response) { 152 | if (this.options.originalRequest) { 153 | this.options.originalRequest.emit(type, data, response); 154 | } else { 155 | this.emit(type, data, response); 156 | } 157 | }, 158 | _fireEvents: function(body, response) { 159 | if (parseInt(response.statusCode) >= 400) this._respond('error', body, response); 160 | else this._respond('success', body, response); 161 | 162 | this._respond(response.statusCode.toString().replace(/\d{2}$/, 'XX'), body, response); 163 | this._respond(response.statusCode.toString(), body, response); 164 | this._respond('complete', body, response); 165 | }, 166 | _makeRequest: function() { 167 | var self = this; 168 | 169 | this.request.on('response', function(response) { 170 | self._responseHandler(response); 171 | }).on('error', function(err) { 172 | self._respond('error', null, err); 173 | }); 174 | }, 175 | run: function() { 176 | var self = this; 177 | 178 | if (this.options.multipart) { 179 | multipart.write(this.request, this.options.data, function() { 180 | self.request.end(); 181 | }); 182 | } else { 183 | if (this.options.data) { 184 | this.request.write(this.options.data.toString(), this.options.encoding || 'utf8'); 185 | } 186 | this.request.end(); 187 | } 188 | 189 | return this; 190 | } 191 | }); 192 | 193 | function shortcutOptions(options, method) { 194 | options = options || {}; 195 | options.method = method; 196 | options.parser = (typeof options.parser !== "undefined") ? options.parser : parsers.auto; 197 | return options; 198 | } 199 | 200 | function request(url, options) { 201 | var request = new Request(url, options); 202 | request.on('error', function() {}); 203 | return request.run(); 204 | } 205 | 206 | function get(url, options) { 207 | return request(url, shortcutOptions(options, 'GET')); 208 | } 209 | 210 | function post(url, options) { 211 | return request(url, shortcutOptions(options, 'POST')); 212 | } 213 | 214 | function put(url, options) { 215 | return request(url, shortcutOptions(options, 'PUT')); 216 | } 217 | 218 | function del(url, options) { 219 | return request(url, shortcutOptions(options, 'DELETE')); 220 | } 221 | 222 | var parsers = { 223 | auto: function(data, callback) { 224 | var contentType = this.headers['content-type']; 225 | 226 | if (contentType) { 227 | for (var matcher in parsers.auto.matchers) { 228 | 229 | if (contentType.indexOf(matcher) == 0) { 230 | return parsers.auto.matchers[matcher].call(this, data, callback); 231 | } 232 | } 233 | } 234 | 235 | callback(data); 236 | }, 237 | json: function(data, callback) { 238 | callback(data && data.length > 1 && JSON.parse(data)); 239 | } 240 | }; 241 | 242 | parsers.auto.matchers = { 243 | 'application/json': parsers.json 244 | }; 245 | 246 | try { 247 | var yaml = require('yaml'); 248 | 249 | parsers.yaml = function(data, callback) { 250 | return callback(data && yaml.eval(data)); 251 | }; 252 | 253 | parsers.auto.matchers['application/yaml'] = parsers.yaml; 254 | } catch(e) {} 255 | 256 | try { 257 | var xml2js = require('xml2js'); 258 | 259 | parsers.xml = function(data, callback) { 260 | if (data) { 261 | var parser = new xml2js.Parser(); 262 | 263 | parser.on('end', function(result) { 264 | callback(result); 265 | }); 266 | try { 267 | parser.parseString(data); 268 | } catch (e) { 269 | callback({error:'Oops, something went wrong.'}); 270 | } 271 | } else { 272 | callback(); 273 | } 274 | }; 275 | 276 | parsers.auto.matchers['application/xml'] = parsers.xml; 277 | } catch(e) { } 278 | 279 | var decoders = { 280 | gzip: function(buf, callback) { 281 | zlib.gunzip(buf, callback); 282 | }, 283 | deflate: function(buf, callback) { 284 | zlib.inflate(buf, callback); 285 | } 286 | }; 287 | 288 | 289 | function Service(defaults) { 290 | if (defaults.baseURL) { 291 | this.baseURL = defaults.baseURL; 292 | delete defaults.baseURL; 293 | } 294 | 295 | this.defaults = defaults; 296 | } 297 | 298 | mixin(Service.prototype, { 299 | request: function(path, options) { 300 | return request(this._url(path), this._withDefaults(options)); 301 | }, 302 | get: function(path, options) { 303 | return get(this._url(path), this._withDefaults(options)); 304 | }, 305 | put: function(path, options) { 306 | return put(this._url(path), this._withDefaults(options)); 307 | }, 308 | post: function(path, options) { 309 | return post(this._url(path), this._withDefaults(options)); 310 | }, 311 | del: function(path, options) { 312 | return del(this._url(path), this._withDefaults(options)); 313 | }, 314 | _url: function(path) { 315 | if (this.baseURL) return url.resolve(this.baseURL, path); 316 | else return path; 317 | }, 318 | _withDefaults: function(options) { 319 | var o = mixin({}, this.defaults); 320 | return mixin(o, options); 321 | } 322 | }); 323 | 324 | function service(constructor, defaults, methods) { 325 | constructor.prototype = new Service(defaults || {}); 326 | mixin(constructor.prototype, methods); 327 | return constructor; 328 | } 329 | 330 | mixin(exports, { 331 | Request: Request, 332 | Service: Service, 333 | request: request, 334 | service: service, 335 | get: get, 336 | post: post, 337 | put: put, 338 | del: del, 339 | parsers: parsers, 340 | file: multipart.file, 341 | data: multipart.data 342 | }); 343 | --------------------------------------------------------------------------------