diff --git a/README.md b/README.md index 6538939..1fa2ef0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ A pluggable HTTP client. - Support for HTTP and HTTPS - Support for streaming -- Pluggable API +- Pluggable API with plugins for: + - following redirects + - compression + - parsing JSON + - authentication + - ... and more ## Usage @@ -16,42 +21,65 @@ A pluggable HTTP client. Callback style: - var Client = require('go-fetch'); + var Client = require('go-fetch'); + var body = require('go-fetch-body-parser'); Client() - .use(Client.plugins.body()) - .get('http://httpbin.org/html', function(error, response) { + .use(body()) + .get('http://httpbin.org/html', function(error, response) { - console.log( - 'Error: '+(error ? error : 'no error')+'\n'+ - 'Status: '+response.getStatus()+'\n'+ - 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n'+ - (response.getBody() ? response.getBody().substr(0, 100)+'...' : '') - ); + console.log( + 'Error: '+(error ? error : 'no error')+'\n'+ + 'Status: '+response.getStatus()+'\n'+ + 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n'+ + (response.getBody() ? response.getBody().substr(0, 100)+'...' : '') + ); - }) + }) ; ### POST Callback style: - - var Client = require('go-fetch'); + + var Client = require('go-fetch'); + var body = require('go-fetch-body-parser'); + var contentType = require('go-fetch-content-type'); Client() - .use(Client.plugins.body()) + .use(contentType) + .use(body.json()) .post('http://httpbin.org/post', {'Content-Type': 'application/json'}, JSON.stringify({msg: 'Go fetch!'}), function(error, response) { console.log( 'Error: '+(error ? error : 'no error')+'\n'+ 'Status: '+response.getStatus()+'\n'+ - 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n'+ - (response.getBody() ? response.getBody().substr(0, 100)+'...' : '') + 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n', + response.getBody() ); - + + }) + ; + +Post a stream: + + var fs = require('fs'); + var Client = require('go-fetch'); + var body = require('go-fetch-body-parser'); + + Client() + .use(body()) + .post('http://httpbin.org/post', {'Content-Type': 'text/x-markdown'}, fs.createReadStream(__dirname+'/../README.md'), function(error, response) { + + console.log( + 'Error: '+(error ? error : 'no error')+'\n'+ + 'Status: '+response.getStatus()+'\n'+ + 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n', + response.getBody() + ); + }) ; - ## API @@ -112,8 +140,15 @@ Remove an event listener. Emitted before the request is sent to the server with the following arguments: -- request : Request -- response : Response +- event : Client.Event + - .getName() : string + - .getEmitter() : Client + - .isDefaultPrevented() : bool + - .preventDefault() + - .isPropagationStopped() : bool + - .stopPropagation() + - .request : Client.Request + - .response : Client.Response Useful for plugins setting data on the request e.g. OAuth signature @@ -121,8 +156,15 @@ Useful for plugins setting data on the request e.g. OAuth signature Emitted after the request is sent to the server with the following arguments: -- request : Request -- response : Response + +- event : Client.Event + - .getName() : string + - .getEmitter() : Client + - .isPropagationStopped() : bool + - .stopPropagation() + - .request : Client.Request + - .response : Client.Response + Useful for plugins processing and setting data on the response e.g. gzip/deflate @@ -188,47 +230,82 @@ Abort the response. ## Plugins -Plugins are functions that are passed the client object do something with it. Plugins are executed when `.use()`d. Using the `before` and `after` events, plugins are able to add helper methods to the `Request` and `Response` objects, modify the request data sent to the server and process the response data received from the server. +Plugins are functions that are passed the client object to do something with it. Plugins are executed when they are `.use()`d. Using the `before` and `after` events, plugins are able to add helper methods to the `Request` and `Response` objects, modify the request data sent to the server, process the response data received from the server, or cancel the request and use a locally built response. ### Example Here's an example plugin that adds an `.isError()` method to the `Response` object. function plugin(client) { - client.on('after', function (request, response) { - - response.isError = function() { - return response.getStatus() >= 400 && response.getStatus() < 600; + client.on('after', function (event) { + + event.response.isError = function() { + return this.getStatus() >= 400 && this.getStatus() < 600; }; }); } -### .prefixUrl(url) +Here's an example plugin that returns a mocked request instead of a real one. + + function(client) { + client.on('before', function(event) { + event.preventDefault(); + event.response + .setStatus(201) + .setHeader('Content-Type', 'application/json; charset=utf-8') + .setBody(JSON.stringify({ + message: 'Hello World!' + })) + ; + }); + } + +### [prefix-url](https://www.npmjs.com/package/go-fetch-prefix-url) -Prefix each request URL with another URL unless the request URL already starts with a prefix of "http(s)://" +Prefix each request URL with another URL. -### .contentType +### [content-type](https://www.npmjs.com/package/go-fetch-content-type) -Parse the `Content-Type` header and add `.contentType` and `.charset` properties to the request object +Parse the Content-Type header. -### [bodyParser](https://www.npmjs.com/package/go-fetch-body-parser) +### [body-parser](https://www.npmjs.com/package/go-fetch-body-parser) -Concatenate the response stream into a string and update the response body. +Concatenate and parse the response stream. -### [OAuth1](https://www.npmjs.com/package/go-fetch-oauth1) +### [auth](https://www.npmjs.com/package/go-fetch-auth) + +Basic HTTP auth. + +### [oauth1](https://www.npmjs.com/package/go-fetch-oauth1) OAuth v1 authentication. +### [follow-redirects](https://www.npmjs.com/package/go-fetch-follow-redirects) + +Automatically follow redirects. + +### [compression](https://www.npmjs.com/package/go-fetch-follow-compression) + +Decompress compressed responses from the server. + ## ToDo - Tests - Plugins: - - Compression (gzip/deflate) - Cookie Jar - OAuth v2 - Support for XMLHttpRequest in the browser +## Changelog + +### v2.0.0 + + - moved `prefixUrl`, `contentType` and `body` plugins into their own repositories + - changed the arguments passed to the `before` and `after` event handlers - handlers now receive a formal event object that allows propagation to be stopped and the request to be prevented + - adding some tests + - cleaning up documentation + ## License The MIT License (MIT) diff --git a/example/GET.js b/example/GET.js index f37a828..a343321 100755 --- a/example/GET.js +++ b/example/GET.js @@ -1,5 +1,5 @@ -var Client = require('..'); -var body = require('go-fetch-body-parser'); +var Client = require('..'); +var body = require('go-fetch-body-parser'); Client() .use(body()) diff --git a/example/POST.js b/example/POST.js index 9b9c286..b1c32d1 100755 --- a/example/POST.js +++ b/example/POST.js @@ -1,8 +1,9 @@ -var Client = require('..'); -var body = require('go-fetch-body-parser'); +var Client = require('..'); +var body = require('go-fetch-body-parser'); +var contentType = require('go-fetch-content-type'); Client() - .use(Client.plugins.contentType) + .use(contentType) .use(body.json()) .post('http://httpbin.org/post', {'Content-Type': 'application/json'}, JSON.stringify({msg: 'Go fetch!'}), function(error, response) { diff --git a/example/POST_stream.js b/example/POST_stream.js new file mode 100755 index 0000000..921e01b --- /dev/null +++ b/example/POST_stream.js @@ -0,0 +1,17 @@ +var fs = require('fs'); +var Client = require('..'); +var body = require('go-fetch-body-parser'); + +Client() + .use(body()) + .post('http://httpbin.org/post', {'Content-Type': 'text/x-markdown'}, fs.createReadStream(__dirname+'/../README.md'), function(error, response) { + + console.log( + 'Error: '+(error ? error : 'no error')+'\n'+ + 'Status: '+response.getStatus()+'\n'+ + 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n', + response.getBody() + ); + + }) +; diff --git a/example/plugin_mock.js b/example/plugin_mock.js new file mode 100644 index 0000000..6fe1abe --- /dev/null +++ b/example/plugin_mock.js @@ -0,0 +1,27 @@ +var Client = require('..'); +var body = require('go-fetch-body-parser'); + +Client() + .use(function(client) { + client.on('before', function(event) { + event.preventDefault(); + event.response + .setStatus(201) + .setHeader('Content-Type', 'application/json; charset=utf-8') + .setBody(JSON.stringify({ + message: 'Hello World!' + })) + ; + }) + }) + .get('http://api.myservice.io', function(error, response) { + + console.log( + 'Error: '+(error ? error : 'no error')+'\n'+ + 'Status: '+response.getStatus()+'\n'+ + 'Headers: '+JSON.stringify(response.getHeaders()).substr(0, 100)+'...'+'\n'+ + (response.getBody() ? response.getBody().substr(0, 100)+'...' : '') + ); + + }) +; diff --git a/lib/Client.js b/lib/Client.js index 0f3617c..ec7b926 100755 --- a/lib/Client.js +++ b/lib/Client.js @@ -1,8 +1,28 @@ +var util = require('util'); var httprequest = require('no-frills-request'); var emitter = require('emitter-on-steroids'); var Request = require('./Request'); var Response = require('./Response'); var Stream = require('./Stream'); +var SteroidEvent = require('emitter-on-steroids/event'); + +/** + * Event + * @constructor + * @param {Object} options + * @param {Object} options.name + * @param {Request} options.request + * @param {Response} options.response + * @param {Object} [options.emitter] + */ +function Event(options) { + SteroidEvent.call(this, options.name, options.emitter); + this.request = options.request; + this.response = options.response; +} +util.inherits(Event, SteroidEvent); +Event.stoppable = SteroidEvent.stoppable; +Event.preventable = SteroidEvent.preventable; //TODO: move the URL to another package, don't return the response, only require a callback - simpler! @@ -33,12 +53,7 @@ emitter(Client.prototype); Client.Request = Request; Client.Response = Response; - -/** - * The plugins - * @type {Object} - */ -Client.plugins = require('./plugins'); +Client.Event = Event; /** * Apply a plugin @@ -62,6 +77,26 @@ Client.prototype.send = function(request, callback) { //create a new response var response = new Response(); + //forward events to the client + request + .on('sent', function() { + self.emit('sent', this); + }) + .on('error', function(error) { + self.emit('error', error); + }) + ; + + //forward events to the client + response + .on('received', function() { + self.emit('received', this); + }) + .on('error', function(error) { + self.emit('error', error); + }) + ; + //register a callback if (callback) { @@ -88,55 +123,90 @@ Client.prototype.send = function(request, callback) { } //allow the plugins to do stuff before the request is sent - self.emit('before', request, response, function(err) { + var event = Event.stoppable(Event.preventable(new Event({ + name: 'before', + request: request, + response: response, + emitter: this + }))); + this.emit(event, function(err, event) { if (err) request.emit('error', err); - //create the request - var req = httprequest.create( - request.getMethod(), - request.getUrl().toString(), - request.getHeaders(), - self.options - ); - - //notify the user when the request has been sent to the server - req.on('finish', function() { - request.emit('sent'); //TODO: forward to the client too - }); + if (event.isDefaultPrevented()) { - //notify the user when an error occurred whilst sending the request - req.on('error', function(err) { - request.emit('error', err); //TODO: forward to the client too - }); + request.emit('sent'); + + //allow the plugins to do stuff after the response is received + var event = Event.stoppable(new Event({ + name: 'after', + request: request, + response: response, + emitter: this + })); + self.emit(event, function(err) { + if (err) response.emit('error', err); - //wrap the response and notify the user when the response has been received - req.on('response', function(res) { + //tell the user the response has been received from the server + response.emit('received'); - //listen for errors - res.on('error', function(err) { - response.emit('error', err); //TODO: forward to the client too }); - //set the response properties - response - .setStatus(res.statusCode) - .setHeaders(res.headers) - .setBody(new Stream(res)) - ; + } else { - //allow the plugins to do stuff before the request is sent - self.emit('after', request, response, function(err) { - if (err) response.emit('error', err); + //create the request + var req = httprequest.create( + request.getMethod(), + request.getUrl().toString(), + request.getHeaders(), + self.options + ); - //tell the user the response has been received from the server - response.emit('received'); //TODO: forward to the client too + //notify the user when the request has been sent to the server + req.on('finish', function() { + request.emit('sent'); + }); + //notify the user when an error occurred whilst sending the request + req.on('error', function(err) { + request.emit('error', err); }); - }); + //wrap the response and notify the user when the response has been received + req.on('response', function(res) { - //send the request - req.send(request.getBody()); + //listen for errors + res.on('error', function(err) { + response.emit('error', err); + }); + + //set the response properties + response + .setStatus(res.statusCode) + .setHeaders(res.headers) + .setBody(new Stream(res)) + ; + + //allow the plugins to do stuff after the response is received + var event = Event.stoppable(Event.preventable(new Event({ + name: 'after', + request: request, + response: response, + emitter: this + }))); + self.emit(event, function(err) { + if (err) response.emit('error', err); + + //tell the user the response has been received from the server + response.emit('received'); + + }); + + }); + + //send the request + req.send(request.getBody()); + + } }); @@ -192,7 +262,6 @@ Client.prototype.request = function(method, url, headers, body, callback) { this.send(request, callback); return this; - } else { var self = this; diff --git a/lib/Url.js b/lib/Url.js index 3ff570c..98e749d 100644 --- a/lib/Url.js +++ b/lib/Url.js @@ -33,7 +33,6 @@ Url.prototype.getPassword = function() { return this._parts.auth ? this._parts.auth.split(':')[1] : ''; }; - /** * Get the host * @returns {string} diff --git a/lib/plugins.js b/lib/plugins.js deleted file mode 100644 index c5aae23..0000000 --- a/lib/plugins.js +++ /dev/null @@ -1,109 +0,0 @@ -var parseHeader = require('parse-http-header'); - -module.exports = { - - /** - * Prefix each request URL with another URL unless the request URL already starts with a prefix of "http(s)://" - * @param {string} url The base URL - * @returns {function(Client)} - */ - prefixUrl: function(url) { - return function(client) { - client.on('before', function(request, response) { - - var partialUrl = request.getUrl().toString(); - - if (!/^http(s)?:\/\//.test(partialUrl)) { - request.setUrl(url+partialUrl); - } - - }); - }; - }, - - /** - * Parse the `Content-Type` header and add `.contentType` and `.charset` properties to the request object - * @param {Client} client - */ - contentType: function(client) { - - /** - * Get the content type - * @returns {string} - */ - function getContentType() { - var header = parseHeader(this.getHeader('Content-Type')+'; '); - if (typeof(header[0]) !== 'undefined') { - return header[0]; - } else { - return null; - } - }; - - /** - * Get the content charset - * @returns {string} - */ - function getCharset() { - var header = parseHeader(this.getHeader('Content-Type')); - if (typeof(header['charset']) !== 'undefined') { - return header['charset']; - } else { - return null; - } - }; - - client.on('before', function (request, response) { - - request.getContentType = getContentType; - request.getCharset = getCharset; - - response.getContentType = getContentType; - response.getCharset = getCharset; - - }); - - }, - - /** - * Concatenate the response stream and add it on a `.body` property on the response object - * - will block the `headers` event from firing until all the content is read - * @param {Object} options The plugin options - * @param {Array.} [options.types] The mime types for which the body is concatenated for. Useful when you don't want to load the whole body for certain types (e.g. binary files which tend to be larger than text files). Defaults to all files. - * @returns {function(Client)} - */ - body: function(options) { - console.warn('The `go-fetch` `body()` plugin is depreciated. Use the `go-fetch-read-body` plugin instead.'); - - /** - * Concatenate all of the stream's content into to a property on the response - * @param {Client} client - */ - return function(client) { - - client.on('after', function (request, response, done) { - var body = ''; - - //if an allowed list of types is specified, then only concatenate responses where the mime type is in the allowed list of types - if (options && options.types) { - if (typeof(response.getContentType()) === 'undefined' || options.types.indexOf(response.getContentType()) === -1) { - return done(); - } - } - - response.getBody().on('data', function (data) { - body += data.toString(); - }); - - response.getBody().on('end', function () { - response.setBody(body); - done(); - }); - - }); - - }; - - } - -}; \ No newline at end of file diff --git a/package.json b/package.json index 0594127..7173895 100755 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "get", "post" ], - "version": "1.2.1", + "version": "2.0.0", "main": "lib/Client.js", "repository": { "type": "git", @@ -21,10 +21,10 @@ }, "dependencies": { "emitter-on-steroids": "^2.0.3", - "no-frills-request": "^1.1.0", - "parse-http-header": "^1.0.0" + "no-frills-request": "^1.1.0" }, "devDependencies": { + "go-fetch-content-type": "^0.1.0", "go-fetch-body-parser": "^1.0.0", "simple-server-setup": "^0.1.0" }, diff --git a/test/Client.js b/test/Client.js index a13b942..5139e1a 100644 --- a/test/Client.js +++ b/test/Client.js @@ -72,11 +72,161 @@ describe('Client', function() { }); - it('should emit `before`'); - it('should emit `after`'); - it('should emit `sent`'); - it('should emit `received`'); - it('should emit `error`'); + it('should emit `before`', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var event; + + Client() + .on('before', function(e) {event=e.getName();}) + .get(srv.url, function(error, response) { + assert.equal(event, 'before'); + srv.close(done); + }) + ; + + }); + + }); + + it('`before` event should be stoppable', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var p1, p2; + + Client() + .on('before', function(e) {p1=true;e.stopPropagation();}) + .on('before', function(e) {p2=true;}) + .get(srv.url, function(error, response) { + assert(p1); + assert(!p2); + srv.close(done); + }) + ; + + }); + + }); + + it('`before` event should be preventable'); + it('should emit `after`', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var event; + + Client() + .on('after', function(e) {event=e.getName();}) + .get(srv.url, function(error, response) { + assert.equal(event, 'after'); + srv.close(done); + }) + ; + + }); + + }); + + it('`after` event should be stoppable', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var p1, p2; + + Client() + .on('after', function(e) {p1=true;e.stopPropagation();}) + .on('after', function(e) {p2=true;}) + .get(srv.url, function(error, response) { + assert(p1); + assert(!p2); + srv.close(done); + }) + ; + + }); + + }); + + it('should emit `sent`', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var called; + + Client() + .on('sent', function() {called=true}) + .get(srv.url, function(error, response) { + assert(called); + srv.close(done); + }) + ; + + }); + + }); + + it('should emit `received`', function(done) { + + var srv = server.create(function(app) { + app.get('/', function(req, res) { + res.send('SERVER'); + }); + }); + + srv.on('configured', function() { + var called; + + Client() + .on('received', function() {called=true}) + .get(srv.url, function(error, response) { + assert(called); + srv.close(done); + }) + ; + + }); + + }); + + it('should emit `error`', function(done) { + var called; + + Client() + .on('error', function() {called=true}) + .get('http://does.not.exist', function(error, response) { + assert(called); + done(); + }) + ; + + }); it('should emit `error` if a plugin fails `before`', function(done) { @@ -90,7 +240,7 @@ describe('Client', function() { Client() .use(function(client) { - client.on('before', function(request, response, next) { + client.on('before', function(event, next) { next(new Error('Plugin error')); }); }) @@ -117,7 +267,7 @@ describe('Client', function() { Client() .use(function (client) { - client.on('after', function (request, response, next) { + client.on('after', function (event, next) { next(new Error('Plugin error')); }); }) diff --git a/test/plugins.js b/test/plugins.js deleted file mode 100644 index 0b01e4f..0000000 --- a/test/plugins.js +++ /dev/null @@ -1,90 +0,0 @@ -var assert = require('assert'); -var Client = require('..'); -var plugins = require('../lib/plugins'); -var Request = require('../lib/Request'); -var Response = require('../lib/Response'); - -describe('Plugins', function() { - - describe('baseUrl', function() { - - it('should not prefix URLs which start with http://', function() { - - var client = new Client(); - var plugin = plugins.prefixUrl('http://api.github.com/'); - var request = new Request('GET', 'http://www.digitaledgeit.com.au/favicon.ico'); - var response = new Response(); - - //init the plugin - plugin(client); - - //run the plugin - client.emit('before', request, response); - - //check the result - assert.equal(request.getUrl().toString(), 'http://www.digitaledgeit.com.au/favicon.ico'); - - }); - - it('should not prefix URLs which start with https://', function() { - - var client = new Client(); - var plugin = plugins.prefixUrl('http://api.github.com/'); - var request = new Request('GET', 'https://www.digitaledgeit.com.au/favicon.ico'); - var response = new Response(); - - //init the plugin - plugin(client); - - //run the plugin - client.emit('before', request, response); - - //check the result - assert.equal(request.getUrl().toString(), 'https://www.digitaledgeit.com.au/favicon.ico'); - - }); - - it('should prefix URLs which do not start with http(s)://', function() { - - var client = new Client(); - var plugin = plugins.prefixUrl('https://api.github.com/'); - var request = new Request('GET', 'users/digitaledgeit/repos'); - var response = new Response(); - - //init the plugin - plugin(client); - - //run the plugin - client.emit('before', request, response); - - //check the result - assert.equal(request.getUrl().toString(), 'https://api.github.com/users/digitaledgeit/repos'); - - }); - - }); - - describe('contentType', function() { - - it('should add methods to the request and response objects', function() { - - var client = new Client(); - var plugin = plugins.contentType; - var request = new Request('GET', 'users/digitaledgeit/repos', {'Content-Type': 'application/json'}); - var response = new Response(); - - //init the plugin - plugin(client); - - //run the plugin - client.emit('before', request, response); - - //check the result - assert.equal(request.getContentType(), 'application/json'); - - }); - - - }); - -});