superagent
Version:
elegant & feature rich browser / node HTTP with a fluent API
839 lines (713 loc) • 16.7 kB
JavaScript
/*!
* superagent
* Copyright (c) 2011 TJ Holowaychuk <tj@vision-media.ca>
* MIT Licensed
*/
/**
* Module dependencies.
*/
var Stream = require('stream').Stream
, formidable = require('formidable')
, Response = require('./response')
, parse = require('url').parse
, format = require('url').format
, methods = require('methods')
, utils = require('./utils')
, Part = require('./part')
, mime = require('mime')
, https = require('https')
, http = require('http')
, fs = require('fs')
, qs = require('qs')
, util = require('util');
/**
* Expose the request function.
*/
exports = module.exports = request;
/**
* Expose the agent function
*/
exports.agent = require('./agent');
/**
* Expose `Part`.
*/
exports.Part = Part;
/**
* Noop.
*/
function noop(){};
/**
* Expose `Response`.
*/
exports.Response = Response;
/**
* Define "form" mime type.
*/
mime.define({
'application/x-www-form-urlencoded': ['form', 'urlencoded', 'form-data']
});
/**
* Protocol map.
*/
exports.protocols = {
'http:': http,
'https:': https
};
/**
* Check if `obj` is an object.
*
* @param {Object} obj
* @return {Boolean}
* @api private
*/
function isObject(obj) {
return null != obj && 'object' == typeof obj;
}
/**
* Default serialization map.
*
* superagent.serialize['application/xml'] = function(obj){
* return 'generated xml here';
* };
*
*/
exports.serialize = {
'application/x-www-form-urlencoded': qs.stringify,
'application/json': JSON.stringify
};
/**
* Default parsers.
*
* superagent.parse['application/xml'] = function(res, fn){
* fn(null, result);
* };
*
*/
exports.parse = require('./parsers');
/**
* Initialize a new `Request` with the given `method` and `url`.
*
* @param {String} method
* @param {String|Object} url
* @api public
*/
function Request(method, url) {
var self = this;
if ('string' != typeof url) url = format(url);
this.method = method;
this.url = url;
this.header = {};
this.writable = true;
this._redirects = 0;
this.redirects(5);
this.attachments = [];
this.cookies = '';
this._redirectList = [];
this.on('end', this.clearTimeout.bind(this));
this.on('response', function(res){
self.callback(null, res);
});
}
/**
* Inherit from `Stream.prototype`.
*/
Request.prototype.__proto__ = Stream.prototype;
/**
* Queue the given `file` as an attachment
* with optional `filename`.
*
* @param {String} field
* @param {String} file
* @param {String} filename
* @return {Request} for chaining
* @api public
*/
Request.prototype.attach = function(field, file, filename){
this.attachments.push({
field: field,
path: file,
part: new Part(this),
filename: filename || file
});
return this;
};
/**
* Set the max redirects to `n`.
*
* @param {Number} n
* @return {Request} for chaining
* @api public
*/
Request.prototype.redirects = function(n){
this._maxRedirects = n;
return this;
};
/**
* Return a new `Part` for this request.
*
* @return {Part}
* @api public
*/
Request.prototype.part = function(){
return new Part(this);
};
/**
* Set header `field` to `val`, or multiple fields with one object.
*
* Examples:
*
* req.get('/')
* .set('Accept', 'application/json')
* .set('X-API-Key', 'foobar')
* .end(callback);
*
* req.get('/')
* .set({ Accept: 'application/json', 'X-API-Key': 'foobar' })
* .end(callback);
*
* @param {String|Object} field
* @param {String} val
* @return {Request} for chaining
* @api public
*/
Request.prototype.set = function(field, val){
if (isObject(field)) {
for (var key in field) {
this.set(key, field[key]);
}
return this;
}
this.request().setHeader(field, val);
return this;
};
/**
* Get request header `field`.
*
* @param {String} field
* @return {String}
* @api public
*/
Request.prototype.get = function(field){
return this.request().getHeader(field);
};
/**
* Set _Content-Type_ response header passed through `mime.lookup()`.
*
* Examples:
*
* request.post('/')
* .type('xml')
* .send(xmlstring)
* .end(callback);
*
* request.post('/')
* .type('json')
* .send(jsonstring)
* .end(callback);
*
* request.post('/')
* .type('application/json')
* .send(jsonstring)
* .end(callback);
*
* @param {String} type
* @return {Request} for chaining
* @api public
*/
Request.prototype.type = function(type){
return this.set('Content-Type', ~type.indexOf('/')
? type
: mime.lookup(type));
};
/**
* Add query-string `val`.
*
* Examples:
*
* request.get('/shoes')
* .query('size=10')
* .query({ color: 'blue' })
*
* @param {Object|String} val
* @return {Request} for chaining
* @api public
*/
Request.prototype.query = function(val){
var req = this.request();
if ('string' != typeof val) val = qs.stringify(val);
if (!val.length) return this;
req.path += (~req.path.indexOf('?') ? '&' : '?') + val;
return this;
};
/**
* Send `data`, defaulting the `.type()` to "json" when
* an object is given.
*
* Examples:
*
* // manual json
* request.post('/user')
* .type('json')
* .send('{"name":"tj"}')
* .end(callback)
*
* // auto json
* request.post('/user')
* .send({ name: 'tj' })
* .end(callback)
*
* // manual x-www-form-urlencoded
* request.post('/user')
* .type('form')
* .send('name=tj')
* .end(callback)
*
* // auto x-www-form-urlencoded
* request.post('/user')
* .type('form')
* .send({ name: 'tj' })
* .end(callback)
*
* // string defaults to x-www-form-urlencoded
* request.post('/user')
* .send('name=tj')
* .send('foo=bar')
* .send('bar=baz')
* .end(callback)
*
* @param {String|Object} data
* @return {Request} for chaining
* @api public
*/
Request.prototype.send = function(data){
var obj = isObject(data);
var req = this.request();
var type = req.getHeader('Content-Type');
// merge
if (obj && isObject(this._data)) {
for (var key in data) {
this._data[key] = data[key];
}
// string
} else if ('string' == typeof data) {
// default to x-www-form-urlencoded
if (!type) this.type('form');
type = req.getHeader('Content-Type');
// concat &
if ('application/x-www-form-urlencoded' == type) {
this._data = this._data
? this._data + '&' + data
: data;
} else {
this._data = (this._data || '') + data;
}
} else {
this._data = data;
}
if (!obj) return this;
// default to json
if (!type) this.type('json');
return this;
};
/**
* Write raw `data` / `encoding` to the socket.
*
* @param {Buffer|String} data
* @param {String} encoding
* @return {Boolean}
* @api public
*/
Request.prototype.write = function(data, encoding){
return this.request().write(data, encoding);
};
/**
* Pipe the request body to `stream`.
*
* @param {Stream} stream
* @param {Object} options
* @return {Request} for chaining
* @api public
*/
Request.prototype.pipe = function(stream, options){
this.buffer(false);
return this.end().req.on('response', function(res){
res.pipe(stream, options);
});
};
/**
* Enable / disable buffering.
*
* @return {Boolean} [val]
* @return {Request} for chaining
* @api public
*/
Request.prototype.buffer = function(val){
this._buffer = false === val
? false
: true;
return this;
};
/**
* Set timeout to `ms`.
*
* @param {Number} ms
* @return {Request} for chaining
* @api public
*/
Request.prototype.timeout = function(ms){
this._timeout = ms;
return this;
};
/**
* Clear previous timeout.
*
* @return {Request} for chaining
* @api public
*/
Request.prototype.clearTimeout = function(){
this._timeout = 0;
clearTimeout(this._timer);
return this;
};
/**
* Define the parser to be used for this response.
*
* @param {Function} fn
* @return {Request} for chaining
* @api public
*/
Request.prototype.parse = function(fn){
this._parser = fn;
return this;
};
/**
* Redirect to `url
*
* @param {IncomingMessage} res
* @return {Request} for chaining
* @api private
*/
Request.prototype.redirect = function(res){
var url = res.headers.location;
if (!~url.indexOf('://')) {
if (0 != url.indexOf('//')) {
url = '//' + this.host + url;
}
url = this.protocol + url;
}
delete this.req;
this.method = 'HEAD' == this.method
? this.method
: 'GET';
this._data = null;
this.url = url;
this._redirectList.push(url);
this.emit('redirect', res);
this.end(this._callback);
return this;
};
/**
* Set Authorization field value with `user` and `pass`.
*
* @param {String} user
* @param {String} pass
* @return {Request} for chaining
* @api public
*/
Request.prototype.auth = function(user, pass){
var str = new Buffer(user + ':' + pass).toString('base64');
return this.set('Authorization', 'Basic ' + str);
};
/**
* Write the field `name` and `val`.
*
* @param {String} name
* @param {String} val
* @return {Request} for chaining
* @api public
*/
Request.prototype.field = function(name, val){
this.part()
.name(name)
.write(val);
return this;
};
/**
* Return an http[s] request.
*
* @return {OutgoingMessage}
* @api private
*/
Request.prototype.request = function(){
if (this.req) return this.req;
var self = this
, options = {}
, data = this._data
, url = this.url;
// default to http://
if (0 != url.indexOf('http')) url = 'http://' + url;
url = parse(url, true);
// options
options.method = this.method;
options.port = url.port;
options.path = url.pathname;
options.host = url.hostname;
// initiate request
var mod = exports.protocols[url.protocol];
// request
var req = this.req = mod.request(options);
this.protocol = url.protocol;
this.host = url.host;
// expose events
req.on('drain', function(){ self.emit('drain'); });
req.on('error', function(err){
// flag abortion here for out timeouts
// because node will emit a faux-error "socket hang up"
// when request is aborted before a connection is made
if (self._aborted) return;
self.callback(err);
});
// auth
if (url.auth) {
var auth = url.auth.split(':');
this.auth(auth[0], auth[1]);
}
// query
this.query(url.query);
// add cookies
req.setHeader('Cookie', this.cookies);
return req;
};
/**
* Invoke the callback with `err` and `res`
* and handle arity check.
*
* @param {Error} err
* @param {Response} res
* @api private
*/
Request.prototype.callback = function(err, res){
var fn = this._callback;
this.clearTimeout();
if (2 == fn.length) return fn(err, res);
if (err) return this.emit('error', err);
fn(res);
};
/**
* Initiate request, invoking callback `fn(err, res)`
* with an instanceof `Response`.
*
* @param {Function} fn
* @return {Request} for chaining
* @api public
*/
Request.prototype.end = function(fn){
var self = this
, data = this._data
, req = this.request()
, buffer = this._buffer
, method = this.method
, timeout = this._timeout;
// store callback
this._callback = fn || noop;
// timeout
if (timeout && !this._timer) {
this._timer = setTimeout(function(){
var err = new Error('timeout of ' + timeout + 'ms exceeded');
err.timeout = timeout;
self._aborted = true;
req.abort();
self.callback(err);
}, timeout);
}
// body
if ('HEAD' != method && !req._headerSent) {
// serialize stuff
if ('string' != typeof data) {
var serialize = exports.serialize[req.getHeader('Content-Type')];
if (serialize) data = serialize(data);
}
// content-length
if (data && !req.getHeader('Content-Length')) {
this.set('Content-Length', Buffer.byteLength(data));
}
}
// response
req.on('response', function(res){
var max = self._maxRedirects
, mime = utils.type(res.headers['content-type'] || '')
, type = mime.split('/')
, subtype = type[1]
, type = type[0]
, multipart = 'multipart' == type
, redirect = isRedirect(res.statusCode);
// redirect
if (redirect && self._redirects++ != max) {
return self.redirect(res);
}
// zlib support
if (/^(deflate|gzip)$/.test(res.headers['content-encoding'])) {
utils.unzip(req, res);
}
// don't buffer multipart
if (multipart) buffer = false;
// TODO: make all parsers take callbacks
if (multipart) {
var form = new formidable.IncomingForm;
form.parse(res, function(err, fields, files){
if (err) throw err;
// TODO: handle error
// TODO: emit formidable events, parse json etc
var response = new Response(req, res);
response.body = fields;
response.files = files;
response.redirects = self._redirectList;
self.emit('end');
self.callback(null, response);
});
return;
}
// by default only buffer text/*, json
// and messed up thing from hell
var text = isText(mime);
if (null == buffer && text) buffer = true;
// parser
var parse = 'text' == type
? exports.parse.text
: exports.parse[mime];
// buffered response
if (buffer) parse = parse || exports.parse.text;
// explicit parser
if (self._parser) parse = self._parser;
// parse
if (parse) {
parse(res, function(err, obj){
// TODO: handle error
res.body = obj;
});
}
// unbuffered
if (!buffer) {
self.res = res;
var response = new Response(self.req, self.res);
response.redirects = self._redirectList;
self.emit('response', response);
return;
}
// end event
self.res = res;
res.on('end', function(){
// TODO: unless buffering emit earlier to stream
var response = new Response(self.req, self.res);
response.redirects = self._redirectList;
self.emit('response', response);
self.emit('end');
});
});
if (this.attachments.length) return this.writeAttachments();
// multi-part boundary
if (this._boundary) this.writeFinalBoundary();
req.end(data);
return this;
};
/**
* Write the final boundary.
*
* @api private
*/
Request.prototype.writeFinalBoundary = function(){
this.request().write('\r\n--' + this._boundary + '--');
};
/**
* Write the attachments in sequence.
*
* @api private
*/
Request.prototype.writeAttachments = function(){
var files = this.attachments
, req = this.request()
, self = this;
function next() {
var file = files.shift();
if (!file) {
self.writeFinalBoundary();
return req.end();
}
file.part.attachment(file.field, file.filename);
var stream = fs.createReadStream(file.path);
// TODO: pipe
// TODO: handle errors
stream.on('data', function(data){
file.part.write(data);
}).on('error', function(err){
self.emit('error', err);
}).on('end', next);
}
next();
};
/**
* Expose `Request`.
*/
exports.Request = Request;
/**
* Issue a request:
*
* Examples:
*
* request('GET', '/users').end(callback)
* request('/users').end(callback)
* request('/users', callback)
*
* @param {String} method
* @param {String|Function} url or callback
* @return {Request}
* @api public
*/
function request(method, url) {
// callback
if ('function' == typeof url) {
return new Request('GET', method).end(url);
}
// url first
if (1 == arguments.length) {
return new Request('GET', method);
}
return new Request(method, url);
}
// generate HTTP verb methods
methods.forEach(function(method){
var name = 'delete' == method
? 'del'
: method;
method = method.toUpperCase();
request[name] = function(url, fn){
var req = request(method, url);
fn && req.end(fn);
return req;
};
});
/**
* Check if `mime` is text and should be buffered.
*
* @param {String} mime
* @return {Boolean}
* @api public
*/
function isText(mime) {
var parts = mime.split('/');
var type = parts[0];
var subtype = parts[1];
return 'text' == type
|| 'json' == subtype
|| 'x-www-form-urlencoded' == subtype;
}
/**
* Check if we should follow the redirect `code`.
*
* @param {Number} code
* @return {Boolean}
* @api private
*/
function isRedirect(code) {
return ~[301, 302, 303, 305, 307].indexOf(code);
}