node-fetch
Advanced tools
Comparing version 1.3.3 to 1.4.0
@@ -8,4 +8,14 @@ | ||
## v1.3.3 (master) | ||
## v1.4.0 (master) | ||
- Enhance: Request and Response now have `clone` method (thx to @kirill-konshin for the initial PR) | ||
- Enhance: Request and Response now have proper string and buffer body support (thx to @kirill-konshin) | ||
- Enhance: Body constructor has been refactored out (thx to @kirill-konshin) | ||
- Enhance: Headers now has `forEach` method (thx to @tricoder42) | ||
- Enhance: back to 100% code coverage | ||
- Fix: better form-data support (thx to @item4) | ||
- Fix: better character encoding detection under chunked encoding (thx to @dsuket for the initial PR) | ||
## v1.3.3 | ||
- Fix: make sure `Content-Length` header is set when body is string for POST/PUT/PATCH requests | ||
@@ -12,0 +22,0 @@ - Fix: handle body stream error, for cases such as incorrect `Content-Encoding` header |
10
index.js
@@ -15,2 +15,3 @@ | ||
var Body = require('./lib/body'); | ||
var Response = require('./lib/response'); | ||
@@ -20,3 +21,6 @@ var Headers = require('./lib/headers'); | ||
// commonjs | ||
module.exports = Fetch; | ||
// es6 default export compatibility | ||
module.exports.default = module.exports; | ||
@@ -41,3 +45,3 @@ /** | ||
Response.Promise = Fetch.Promise; | ||
Body.Promise = Fetch.Promise; | ||
@@ -92,2 +96,5 @@ var self = this; | ||
headers.set('content-length', Buffer.byteLength(options.body)); | ||
// detect form data input from form-data module, this hack avoid the need to add content-length header manually | ||
} else if (options.body && typeof options.body.getLengthSync === 'function') { | ||
headers.set('content-length', options.body.getLengthSync().toString()); | ||
// this is only necessary for older nodejs releases (before iojs merge) | ||
@@ -172,2 +179,3 @@ } else if (options.body === undefined || options.body === null) { | ||
, status: res.statusCode | ||
, statusText: res.statusMessage | ||
, headers: headers | ||
@@ -174,0 +182,0 @@ , size: options.size |
@@ -1,2 +0,1 @@ | ||
/** | ||
@@ -73,2 +72,17 @@ * headers.js | ||
/** | ||
* Iterate over all headers | ||
* | ||
* @param Function callback Executed for each item with parameters (value, name, thisArg) | ||
* @param Boolean thisArg `this` context for callback function | ||
* @return Void | ||
*/ | ||
Headers.prototype.forEach = function(callback, thisArg) { | ||
Object.getOwnPropertyNames(this._headers).forEach(function(name) { | ||
this._headers[name].forEach(function(value) { | ||
callback.call(thisArg, value, name, this) | ||
}, this) | ||
}, this) | ||
} | ||
/** | ||
* Overwrite header values given name | ||
@@ -75,0 +89,0 @@ * |
@@ -1,2 +0,1 @@ | ||
/** | ||
@@ -10,2 +9,3 @@ * request.js | ||
var Headers = require('./headers'); | ||
var Body = require('./body'); | ||
@@ -48,3 +48,2 @@ module.exports = Request; | ||
this.headers = new Headers(init.headers || input.headers || {}); | ||
this.body = init.body || input.body; | ||
this.url = url; | ||
@@ -56,10 +55,13 @@ | ||
input.follow : 20; | ||
this.counter = init.counter || input.follow || 0; | ||
this.timeout = init.timeout || input.timeout || 0; | ||
this.compress = init.compress !== undefined ? | ||
init.compress : input.compress !== undefined ? | ||
input.compress : true; | ||
this.size = init.size || input.size || 0; | ||
this.agent = init.agent || input.agent; | ||
this.counter = init.counter || input.counter || input.follow || 0; | ||
this.agent = init.agent || input.agent || input.agent; | ||
Body.call(this, init.body || this._clone(input), { | ||
timeout: init.timeout || input.timeout || 0, | ||
size: init.size || input.size || 0 | ||
}); | ||
// server request options | ||
@@ -72,1 +74,12 @@ this.protocol = url_parsed.protocol; | ||
} | ||
Request.prototype = Object.create(Body.prototype); | ||
/** | ||
* Clone this request | ||
* | ||
* @return Request | ||
*/ | ||
Request.prototype.clone = function() { | ||
return new Request(this); | ||
}; |
@@ -1,2 +0,1 @@ | ||
/** | ||
@@ -9,4 +8,4 @@ * response.js | ||
var http = require('http'); | ||
var convert = require('encoding').convert; | ||
var Headers = require('./headers'); | ||
var Body = require('./body'); | ||
@@ -28,161 +27,25 @@ module.exports = Response; | ||
this.status = opts.status; | ||
this.statusText = http.STATUS_CODES[this.status]; | ||
this.statusText = opts.statusText || http.STATUS_CODES[this.status]; | ||
this.headers = new Headers(opts.headers); | ||
this.body = body; | ||
this.bodyUsed = false; | ||
this.size = opts.size; | ||
this.ok = this.status >= 200 && this.status < 300; | ||
this.timeout = opts.timeout; | ||
} | ||
Body.call(this, body, opts); | ||
/** | ||
* Decode response as json | ||
* | ||
* @return Promise | ||
*/ | ||
Response.prototype.json = function() { | ||
return this._decode().then(function(text) { | ||
return JSON.parse(text); | ||
}); | ||
} | ||
/** | ||
* Decode response as text | ||
* | ||
* @return Promise | ||
*/ | ||
Response.prototype.text = function() { | ||
Response.prototype = Object.create(Body.prototype); | ||
return this._decode(); | ||
} | ||
/** | ||
* Decode buffers into utf-8 string | ||
* Clone this response | ||
* | ||
* @return Promise | ||
* @return Response | ||
*/ | ||
Response.prototype._decode = function() { | ||
var self = this; | ||
if (this.bodyUsed) { | ||
return Response.Promise.reject(new Error('body used already for: ' + this.url)); | ||
} | ||
this.bodyUsed = true; | ||
this._bytes = 0; | ||
this._abort = false; | ||
this._raw = []; | ||
return new Response.Promise(function(resolve, reject) { | ||
var resTimeout; | ||
// allow timeout on slow response body | ||
if (self.timeout) { | ||
resTimeout = setTimeout(function() { | ||
self._abort = true; | ||
reject(new Error('response timeout at ' + self.url + ' over limit: ' + self.timeout)); | ||
}, self.timeout); | ||
} | ||
// handle stream error, such as incorrect content-encoding | ||
self.body.on('error', function(err) { | ||
reject(new Error('invalid response body at: ' + self.url + ' reason: ' + err.message)); | ||
}); | ||
self.body.on('data', function(chunk) { | ||
if (self._abort || chunk === null) { | ||
return; | ||
} | ||
if (self.size && self._bytes + chunk.length > self.size) { | ||
self._abort = true; | ||
reject(new Error('content size at ' + self.url + ' over limit: ' + self.size)); | ||
return; | ||
} | ||
self._bytes += chunk.length; | ||
self._raw.push(chunk); | ||
}); | ||
self.body.on('end', function() { | ||
if (self._abort) { | ||
return; | ||
} | ||
clearTimeout(resTimeout); | ||
resolve(self._convert()); | ||
}); | ||
Response.prototype.clone = function() { | ||
return new Response(this._clone(this), { | ||
url: this.url | ||
, status: this.status | ||
, statusText: this.statusText | ||
, headers: this.headers | ||
, ok: this.ok | ||
}); | ||
}; | ||
/** | ||
* Detect buffer encoding and convert to target encoding | ||
* ref: http://www.w3.org/TR/2011/WD-html5-20110113/parsing.html#determining-the-character-encoding | ||
* | ||
* @param String encoding Target encoding | ||
* @return String | ||
*/ | ||
Response.prototype._convert = function(encoding) { | ||
encoding = encoding || 'utf-8'; | ||
var charset = 'utf-8'; | ||
var res, str; | ||
// header | ||
if (this.headers.has('content-type')) { | ||
res = /charset=([^;]*)/i.exec(this.headers.get('content-type')); | ||
} | ||
// no charset in content type, peek at response body | ||
if (!res && this._raw.length > 0) { | ||
str = this._raw[0].toString().substr(0, 1024); | ||
} | ||
// html5 | ||
if (!res && str) { | ||
res = /<meta.+?charset=(['"])(.+?)\1/i.exec(str); | ||
} | ||
// html4 | ||
if (!res && str) { | ||
res = /<meta[\s]+?http-equiv=(['"])content-type\1[\s]+?content=(['"])(.+?)\2/i.exec(str); | ||
if (res) { | ||
res = /charset=(.*)/i.exec(res.pop()); | ||
} | ||
} | ||
// xml | ||
if (!res && str) { | ||
res = /<\?xml.+?encoding=(['"])(.+?)\1/i.exec(str); | ||
} | ||
// found charset | ||
if (res) { | ||
charset = res.pop(); | ||
// prevent decode issues when sites use incorrect encoding | ||
// ref: https://hsivonen.fi/encoding-menu/ | ||
if (charset === 'gb2312' || charset === 'gbk') { | ||
charset = 'gb18030'; | ||
} | ||
} | ||
// turn raw buffers into utf-8 string | ||
return convert( | ||
Buffer.concat(this._raw) | ||
, encoding | ||
, charset | ||
).toString(); | ||
} | ||
// expose Promise | ||
Response.Promise = global.Promise; |
{ | ||
"name": "node-fetch", | ||
"version": "1.3.3", | ||
"version": "1.4.0", | ||
"description": "A light-weight module that brings window.fetch to node.js and io.js", | ||
@@ -27,16 +27,17 @@ "main": "index.js", | ||
"devDependencies": { | ||
"bluebird": "^2.9.1", | ||
"chai": "^1.10.0", | ||
"chai-as-promised": "^4.1.1", | ||
"bluebird": "^3.3.4", | ||
"chai": "^3.5.0", | ||
"chai-as-promised": "^5.2.0", | ||
"coveralls": "^2.11.2", | ||
"form-data": "^1.0.0-rc1", | ||
"istanbul": "^0.3.5", | ||
"istanbul": "^0.4.2", | ||
"mocha": "^2.1.0", | ||
"parted": "^0.1.1", | ||
"promise": "^6.1.0", | ||
"promise": "^7.1.1", | ||
"resumer": "0.0.0" | ||
}, | ||
"dependencies": { | ||
"encoding": "^0.1.11" | ||
"encoding": "^0.1.11", | ||
"is-stream": "^1.0.1" | ||
} | ||
} |
@@ -44,2 +44,8 @@ | ||
if (p === '/options') { | ||
res.statusCode = 200; | ||
res.setHeader('Allow', 'GET, HEAD, OPTIONS'); | ||
res.end('hello world'); | ||
} | ||
if (p === '/html') { | ||
@@ -172,2 +178,25 @@ res.statusCode = 200; | ||
if (p === '/encoding/chunked') { | ||
res.statusCode = 200; | ||
res.setHeader('Content-Type', 'text/html'); | ||
res.setHeader('Transfer-Encoding', 'chunked'); | ||
var padding = 'a'; | ||
for (var i = 0; i < 10; i++) { | ||
res.write(padding); | ||
} | ||
res.end(convert('<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS" /><div>日本語</div>', 'Shift_JIS')); | ||
} | ||
if (p === '/encoding/invalid') { | ||
res.statusCode = 200; | ||
res.setHeader('Content-Type', 'text/html'); | ||
res.setHeader('Transfer-Encoding', 'chunked'); | ||
// because node v0.12 doesn't have str.repeat | ||
var padding = new Array(120 + 1).join('a'); | ||
for (var i = 0; i < 10; i++) { | ||
res.write(padding); | ||
} | ||
res.end(convert('中文', 'gbk')); | ||
} | ||
if (p === '/redirect/301') { | ||
@@ -174,0 +203,0 @@ res.statusCode = 301; |
234
test/test.js
@@ -13,2 +13,3 @@ | ||
var FormData = require('form-data'); | ||
var http = require('http'); | ||
@@ -22,2 +23,3 @@ var TestServer = require('./server'); | ||
var Request = require('../lib/request.js'); | ||
var Body = require('../lib/body.js'); | ||
// test with native promise on node 0.11, and bluebird for node 0.10 | ||
@@ -528,2 +530,3 @@ fetch.Promise = fetch.Promise || bluebird; | ||
expect(res.headers['content-type']).to.contain('multipart/form-data'); | ||
expect(res.headers['content-length']).to.be.a('string'); | ||
expect(res.body).to.equal('a=1'); | ||
@@ -551,2 +554,3 @@ }); | ||
expect(res.headers['content-type']).to.contain('multipart/form-data'); | ||
expect(res.headers['content-length']).to.be.a('string'); | ||
expect(res.headers.b).to.equal('2'); | ||
@@ -610,2 +614,15 @@ expect(res.body).to.equal('a=1'); | ||
it('should allow OPTIONS request', function() { | ||
url = base + '/options'; | ||
opts = { | ||
method: 'OPTIONS' | ||
}; | ||
return fetch(url, opts).then(function(res) { | ||
expect(res.status).to.equal(200); | ||
expect(res.statusText).to.equal('OK'); | ||
expect(res.headers.get('allow')).to.equal('GET, HEAD, OPTIONS'); | ||
expect(res.body).to.be.an.instanceof(stream.Transform); | ||
}); | ||
}); | ||
it('should reject decoding body twice', function() { | ||
@@ -717,2 +734,26 @@ url = base + '/plain'; | ||
it('should support chunked encoding, html4 detect', function() { | ||
url = base + '/encoding/chunked'; | ||
return fetch(url).then(function(res) { | ||
expect(res.status).to.equal(200); | ||
// because node v0.12 doesn't have str.repeat | ||
var padding = new Array(10 + 1).join('a'); | ||
return res.text().then(function(result) { | ||
expect(result).to.equal(padding + '<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS" /><div>日本語</div>'); | ||
}); | ||
}); | ||
}); | ||
it('should only do encoding detection up to 1024 bytes', function() { | ||
url = base + '/encoding/invalid'; | ||
return fetch(url).then(function(res) { | ||
expect(res.status).to.equal(200); | ||
// because node v0.12 doesn't have str.repeat | ||
var padding = new Array(1200 + 1).join('a'); | ||
return res.text().then(function(result) { | ||
expect(result).to.not.equal(padding + '中文'); | ||
}); | ||
}); | ||
}); | ||
it('should allow piping response body as stream', function(done) { | ||
@@ -734,2 +775,58 @@ url = base + '/hello'; | ||
it('should allow cloning a response, and use both as stream', function(done) { | ||
url = base + '/hello'; | ||
return fetch(url).then(function(res) { | ||
var counter = 0; | ||
var r1 = res.clone(); | ||
expect(res.body).to.be.an.instanceof(stream.Transform); | ||
expect(r1.body).to.be.an.instanceof(stream.Transform); | ||
res.body.on('data', function(chunk) { | ||
if (chunk === null) { | ||
return; | ||
} | ||
expect(chunk.toString()).to.equal('world'); | ||
}); | ||
res.body.on('end', function() { | ||
counter++; | ||
if (counter == 2) { | ||
done(); | ||
} | ||
}); | ||
r1.body.on('data', function(chunk) { | ||
if (chunk === null) { | ||
return; | ||
} | ||
expect(chunk.toString()).to.equal('world'); | ||
}); | ||
r1.body.on('end', function() { | ||
counter++; | ||
if (counter == 2) { | ||
done(); | ||
} | ||
}); | ||
}); | ||
}); | ||
it('should allow cloning a json response, and log it as text response', function() { | ||
url = base + '/json'; | ||
return fetch(url).then(function(res) { | ||
var r1 = res.clone(); | ||
return fetch.Promise.all([r1.text(), res.json()]).then(function(results) { | ||
expect(results[0]).to.equal('{"name":"value"}'); | ||
expect(results[1]).to.deep.equal({name: 'value'}); | ||
}); | ||
}); | ||
}); | ||
it('should not allow cloning a response after its been used', function() { | ||
url = base + '/hello'; | ||
return fetch(url).then(function(res) { | ||
return res.text().then(function(result) { | ||
expect(function() { | ||
var r1 = res.clone(); | ||
}).to.throw(Error); | ||
}); | ||
}) | ||
}); | ||
it('should allow get all responses of a header', function() { | ||
@@ -745,2 +842,24 @@ url = base + '/cookie'; | ||
it('should allow iterating through all headers', function() { | ||
var headers = new Headers({ | ||
a: 1 | ||
, b: [2, 3] | ||
, c: [4] | ||
}); | ||
expect(headers).to.have.property('forEach'); | ||
var result = []; | ||
headers.forEach(function(val, key) { | ||
result.push([key, val]); | ||
}); | ||
expected = [ | ||
["a", "1"] | ||
, ["b", "2"] | ||
, ["b", "3"] | ||
, ["c", "4"] | ||
]; | ||
expect(result).to.deep.equal(expected); | ||
}); | ||
it('should allow deleting header', function() { | ||
@@ -812,3 +931,2 @@ url = base + '/cookie'; | ||
expect(h1._headers['a']).to.not.include('2'); | ||
expect(h1._headers['b']).to.not.include('1'); | ||
@@ -880,5 +998,3 @@ expect(h2._headers['a']).to.include('1'); | ||
it('should support parsing headers in Response constructor', function() { | ||
var body = resumer().queue('a=1').end(); | ||
body = body.pipe(new stream.PassThrough()); | ||
var res = new Response(body, { | ||
var res = new Response(null, { | ||
headers: { | ||
@@ -889,2 +1005,6 @@ a: '1' | ||
expect(res.headers.get('a')).to.equal('1'); | ||
}); | ||
it('should support text() method in Response constructor', function() { | ||
var res = new Response('a=1'); | ||
return res.text().then(function(result) { | ||
@@ -895,2 +1015,52 @@ expect(result).to.equal('a=1'); | ||
it('should support json() method in Response constructor', function() { | ||
var res = new Response('{"a":1}'); | ||
return res.json().then(function(result) { | ||
expect(result.a).to.equal(1); | ||
}); | ||
}); | ||
it('should support clone() method in Response constructor', function() { | ||
var res = new Response('a=1', { | ||
headers: { | ||
a: '1' | ||
} | ||
, url: base | ||
, status: 346 | ||
, statusText: 'production' | ||
}); | ||
var cl = res.clone(); | ||
expect(cl.headers.get('a')).to.equal('1'); | ||
expect(cl.url).to.equal(base); | ||
expect(cl.status).to.equal(346); | ||
expect(cl.statusText).to.equal('production'); | ||
expect(cl.ok).to.be.false; | ||
return cl.text().then(function(result) { | ||
expect(result).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support stream as body in Response constructor', function() { | ||
var body = resumer().queue('a=1').end(); | ||
body = body.pipe(new stream.PassThrough()); | ||
var res = new Response(body); | ||
return res.text().then(function(result) { | ||
expect(result).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support string as body in Response constructor', function() { | ||
var res = new Response('a=1'); | ||
return res.text().then(function(result) { | ||
expect(result).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support buffer as body in Response constructor', function() { | ||
var res = new Response(new Buffer('a=1')); | ||
return res.text().then(function(result) { | ||
expect(result).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support parsing headers in Request constructor', function() { | ||
@@ -907,2 +1077,58 @@ url = base; | ||
it('should support text() method in Request constructor', function() { | ||
url = base; | ||
var req = new Request(url, { | ||
body: 'a=1' | ||
}); | ||
expect(req.url).to.equal(url); | ||
return req.text().then(function(result) { | ||
expect(result).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support json() method in Request constructor', function() { | ||
url = base; | ||
var req = new Request(url, { | ||
body: '{"a":1}' | ||
}); | ||
expect(req.url).to.equal(url); | ||
return req.json().then(function(result) { | ||
expect(result.a).to.equal(1); | ||
}); | ||
}); | ||
it('should support clone() method in Request constructor', function() { | ||
url = base; | ||
var agent = new http.Agent(); | ||
var req = new Request(url, { | ||
body: 'a=1' | ||
, method: 'POST' | ||
, headers: { | ||
b: '2' | ||
} | ||
, follow: 3 | ||
, compress: false | ||
, agent: agent | ||
}); | ||
var cl = req.clone(); | ||
expect(cl.url).to.equal(url); | ||
expect(cl.method).to.equal('POST'); | ||
expect(cl.headers.get('b')).to.equal('2'); | ||
expect(cl.follow).to.equal(3); | ||
expect(cl.compress).to.equal(false); | ||
expect(cl.method).to.equal('POST'); | ||
expect(cl.counter).to.equal(3); | ||
expect(cl.agent).to.equal(agent); | ||
return fetch.Promise.all([cl.text(), req.text()]).then(function(results) { | ||
expect(results[0]).to.equal('a=1'); | ||
expect(results[1]).to.equal('a=1'); | ||
}); | ||
}); | ||
it('should support text() and json() method in Body constructor', function() { | ||
var body = new Body('a=1'); | ||
expect(body).to.have.property('text'); | ||
expect(body).to.have.property('json'); | ||
}); | ||
it('should support https request', function() { | ||
@@ -909,0 +1135,0 @@ this.timeout(5000); |
Sorry, the diff of this file is not supported yet
65709
14
1855
2
91
+ Addedis-stream@^1.0.1
+ Addedis-stream@1.1.0(transitive)