/** * Module dependencies. */ var debug = require('debug')('superagent'); var formidable = require('formidable'); var FormData = require('form-data'); var Response = require('./response'); var parse = require('url').parse; var format = require('url').format; var resolve = require('url').resolve; var methods = require('methods'); var Stream = require('stream'); var utils = require('../utils'); var unzip = require('./unzip').unzip; var extend = require('extend'); var mime = require('mime'); var https = require('https'); var http = require('http'); var fs = require('fs'); var qs = require('qs'); var zlib = require('zlib'); var util = require('util'); var pkg = require('../../package.json'); var RequestBase = require('../request-base'); var isFunction = require('../is-function'); var shouldRetry = require('../should-retry'); var request = exports = module.exports = function(method, url) { // callback if ('function' == typeof url) { return new exports.Request('GET', method).end(url); } // url first if (1 == arguments.length) { return new exports.Request('GET', method); } return new exports.Request(method, url); } /** * Expose `Request`. */ exports.Request = Request; /** * Expose the agent function */ exports.agent = require('./agent'); /** * 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 }; /** * 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, res); * }; * */ exports.parse = require('./parsers'); /** * Initialize internal header tracking properties on a request instance. * * @param {Object} req the instance * @api private */ function _initHeaders(req) { var ua = 'node-superagent/' + pkg.version; req._header = { // coerces header names to lowercase 'user-agent': ua }; req.header = { // preserves header name case 'User-Agent': ua }; } /** * Initialize a new `Request` with the given `method` and `url`. * * @param {String} method * @param {String|Object} url * @api public */ function Request(method, url) { Stream.call(this); if ('string' != typeof url) url = format(url); this._agent = false; this._formData = null; this.method = method; this.url = url; _initHeaders(this); this.writable = true; this._redirects = 0; this.redirects(method === 'HEAD' ? 0 : 5); this.cookies = ''; this.qs = {}; this.qsRaw = []; this._redirectList = []; this._streamRequest = false; this.once('end', this.clearTimeout.bind(this)); } /** * Inherit from `Stream` (which inherits from `EventEmitter`). * Mixin `RequestBase`. */ util.inherits(Request, Stream); RequestBase(Request.prototype); /** * Queue the given `file` as an attachment to the specified `field`, * with optional `options` (or filename). * * ``` js * request.post('http://localhost/upload') * .attach(new Buffer('Hello world'), 'hello.html') * .end(callback); * ``` * * A filename may also be used: * * ``` js * request.post('http://localhost/upload') * .attach('files', 'image.jpg') * .end(callback); * ``` * * @param {String} field * @param {String|fs.ReadStream|Buffer} file * @param {String|Object} options * @return {Request} for chaining * @api public */ Request.prototype.attach = function(field, file, options){ if (file) { if (this._data) { throw Error("superagent can't mix .send() and .attach()"); } var o = options || {}; if ('string' == typeof options) { o = { filename: options }; } if ('string' == typeof file) { if (!o.filename) o.filename = file; debug('creating `fs.ReadStream` instance for file: %s', file); file = fs.createReadStream(file); } else if (!o.filename && file.path) { o.filename = file.path; } this._getFormData().append(field, file, o); } return this; }; Request.prototype._getFormData = function() { if (!this._formData) { this._formData = new FormData(); var that = this; this._formData.on('error', function(err) { that.emit('error', err); that.abort(); }); } return this._formData; }; /** * Gets/sets the `Agent` to use for this HTTP request. The default (if this * function is not called) is to opt out of connection pooling (`agent: false`). * * @param {http.Agent} agent * @return {http.Agent} * @api public */ Request.prototype.agent = function(agent){ if (!arguments.length) return this._agent; this._agent = agent; return this; }; /** * 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)); }; /** * Set _Accept_ response header passed through `mime.lookup()`. * * Examples: * * superagent.types.json = 'application/json'; * * request.get('/agent') * .accept('json') * .end(callback); * * request.get('/agent') * .accept('application/json') * .end(callback); * * @param {String} accept * @return {Request} for chaining * @api public */ Request.prototype.accept = function(type){ return this.set('Accept', ~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){ if ('string' == typeof val) { this.qsRaw.push(val); return this; } extend(this.qs, val); 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){ var req = this.request(); if (!this._streamRequest) { this._streamRequest = true; } return req.write(data, encoding); }; /** * Pipe the request body to `stream`. * * @param {Stream} stream * @param {Object} options * @return {Stream} * @api public */ Request.prototype.pipe = function(stream, options){ this.piped = true; // HACK... this.buffer(false); this.end(); return this._pipeContinue(stream, options); }; Request.prototype._pipeContinue = function(stream, options){ var self = this; this.req.once('response', function(res){ // redirect var redirect = isRedirect(res.statusCode); if (redirect && self._redirects++ != self._maxRedirects) { return self._redirect(res)._pipeContinue(stream, options); } self.res = res; self._emitResponse(); if (self._aborted) return; if (self._shouldUnzip(res)) { res.pipe(zlib.createUnzip()).pipe(stream, options); } else { res.pipe(stream, options); } res.once('end', function(){ self.emit('end'); }); }); return stream; }; /** * Enable / disable buffering. * * @return {Boolean} [val] * @return {Request} for chaining * @api public */ Request.prototype.buffer = function(val){ this._buffer = (false !== val); 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) { return this.callback(new Error('No location header for redirect'), res); } debug('redirect %s -> %s', this.url, url); // location url = resolve(this.url, url); // ensure the response is being consumed // this is required for Node v0.10+ res.resume(); var headers = this.req._headers; var shouldStripCookie = parse(url).host !== parse(this.url).host; // implementation of 302 following defacto standard if (res.statusCode == 301 || res.statusCode == 302){ // strip Content-* related fields // in case of POST etc headers = utils.cleanHeader(this.req._headers, shouldStripCookie); // force GET this.method = 'HEAD' == this.method ? 'HEAD' : 'GET'; // clear data this._data = null; } // 303 is always GET if (res.statusCode == 303) { // strip Content-* related fields // in case of POST etc headers = utils.cleanHeader(this.req._headers, shouldStripCookie); // force method this.method = 'GET'; // clear data this._data = null; } // 307 preserves method // 308 preserves method delete headers.host; delete this.req; delete this._formData; // remove all add header except User-Agent _initHeaders(this) // redirect this._endCalled = false; this.url = url; this.qs = {}; this.qsRaw = []; this.set(headers); this.emit('redirect', res); this._redirectList.push(this.url); this.end(this._callback); return this; }; /** * Set Authorization field value with `user` and `pass`. * * Examples: * * .auth('tobi', 'learnboost') * .auth('tobi:learnboost') * .auth('tobi') * .auth(accessToken, { type: 'bearer' }) * * @param {String} user * @param {String} [pass] * @param {Object} [options] options with authorization type 'basic' or 'bearer' ('basic' is default) * @return {Request} for chaining * @api public */ Request.prototype.auth = function(user, pass, options){ if (1 === arguments.length) pass = ''; if (2 === arguments.length && typeof pass === 'object') options = pass; if (!options) { options = { type: 'basic' }; } switch (options.type) { case 'bearer': return this.set('Authorization', 'Bearer ' + user); default: // 'basic' if (!~user.indexOf(':')) user = user + ':'; var str = new Buffer(user + pass).toString('base64'); return this.set('Authorization', 'Basic ' + str); } }; /** * Set the certificate authority option for https request. * * @param {Buffer | Array} cert * @return {Request} for chaining * @api public */ Request.prototype.ca = function(cert){ this._ca = cert; return this; }; /** * Set the client certificate key option for https request. * * @param {Buffer | String} cert * @return {Request} for chaining * @api public */ Request.prototype.key = function(cert){ this._key = cert; return this; }; /** * Set the key, certificate, and CA certs of the client in PFX or PKCS12 format. * * @param {Buffer | String} cert * @return {Request} for chaining * @api public */ Request.prototype.pfx = function(cert){ this._pfx = cert; return this; }; /** * Set the client certificate option for https request. * * @param {Buffer | String} cert * @return {Request} for chaining * @api public */ Request.prototype.cert = function(cert){ this._cert = cert; return this; }; /** * Return an http[s] request. * * @return {OutgoingMessage} * @api private */ Request.prototype.request = function(){ if (this.req) return this.req; var self = this; var options = {}; var url = this.url; var retries = this._retries; // default to http:// if (0 != url.indexOf('http')) url = 'http://' + url; url = parse(url); // support unix sockets if (/^https?\+unix:/.test(url.protocol) === true) { // get the protocol url.protocol = url.protocol.split('+')[0] + ':'; // get the socket, path var unixParts = url.path.match(/^([^/]+)(.+)$/); options.socketPath = unixParts[1].replace(/%2F/g, '/'); url.pathname = unixParts[2]; } // options options.method = this.method; options.port = url.port; options.path = url.pathname; options.host = url.hostname; options.ca = this._ca; options.key = this._key; options.pfx = this._pfx; options.cert = this._cert; options.agent = this._agent; // initiate request var mod = exports.protocols[url.protocol]; // request var req = this.req = mod.request(options); if ('HEAD' != options.method) { req.setHeader('Accept-Encoding', 'gzip, deflate'); } this.protocol = url.protocol; this.host = url.host; // expose events req.once('drain', function(){ self.emit('drain'); }); req.once('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; // if not the same, we are in the **old** (cancelled) request, // so need to continue (same as for above) if (self._retries !== retries) return; // if we've received a response then we don't want to let // an error in the request blow up the response if (self.response) return; self.callback(err); }); // auth if (url.auth) { var auth = url.auth.split(':'); this.auth(auth[0], auth[1]); } // query if (url.search) this.query(url.search.substr(1)); // add cookies if (this.cookies) req.setHeader('Cookie', this.cookies); for (var key in this.header) { req.setHeader(key, this.header[key]); } try { this._appendQueryString(req); } catch (e) { return this.emit('error', e); } 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){ // console.log(this._retries, this._maxRetries) if (this._maxRetries && this._retries++ < this._maxRetries && shouldRetry(err, res)) { return this._retry(); } // Avoid the error which is emitted from 'socket hang up' to cause the fn undefined error on JS runtime. var fn = this._callback || noop; this.clearTimeout(); if (this.called) return console.warn('superagent: double callback bug'); this.called = true; if (!err) { if (this._isResponseOK(res)) { return fn(err, res); } var msg = 'Unsuccessful HTTP response'; if (res) { msg = http.STATUS_CODES[res.status] || msg; } err = new Error(msg); err.status = res ? res.status : undefined; } err.response = res; if (this._maxRetries) err.retries = this._retries - 1; // only emit error event if there is a listener // otherwise we assume the callback to `.end()` will get the error if (err && this.listeners('error').length > 0) { this.emit('error', err); } fn(err, res); }; /** * Compose querystring to append to req.path * * @return {String} querystring * @api private */ Request.prototype._appendQueryString = function(req){ var query = qs.stringify(this.qs, { indices: false, strictNullHandling: true }); query += ((query.length && this.qsRaw.length) ? '&' : '') + this.qsRaw.join('&'); req.path += query.length ? (~req.path.indexOf('?') ? '&' : '?') + query : ''; if (this._sort) { var index = req.path.indexOf('?'); if (index >= 0) { var queryArr = req.path.substring(index + 1).split('&'); if (isFunction(this._sort)) { queryArr.sort(this._sort); } else { queryArr.sort(); } req.path = req.path.substring(0, index) + '?' + queryArr.join('&'); } } }; /** * Check if `obj` is a host object, * * @param {Object} obj * @return {Boolean} * @api private */ Request.prototype._isHost = function _isHost(obj) { return Buffer.isBuffer(obj) || obj instanceof Stream || obj instanceof FormData; } /** * Initiate request, invoking callback `fn(err, res)` * with an instanceof `Response`. * * @param {Function} fn * @return {Request} for chaining * @api public */ Request.prototype._emitResponse = function(body, files){ var response = new Response(this); this.response = response; response.redirects = this._redirectList; if (undefined !== body) { response.body = body; } response.files = files; this.emit('response', response); return response; }; Request.prototype.end = function(fn){ this.request(); debug('%s %s', this.method, this.url); if (this._endCalled) { console.warn("Warning: .end() was called twice. This is not supported in superagent"); } this._endCalled = true; // store callback this._callback = fn || noop; return this._end(); }; Request.prototype._end = function() { var self = this; var data = this._data; var req = this.req; var buffer = this._buffer; var method = this.method; this._setTimeouts(); // body if ('HEAD' != method && !req._headerSent) { // serialize stuff if ('string' != typeof data) { var contentType = req.getHeader('Content-Type') // Parse out just the content type from the header (ignore the charset) if (contentType) contentType = contentType.split(';')[0] var serialize = exports.serialize[contentType]; if (!serialize && isJSON(contentType)) { serialize = exports.serialize['application/json']; } if (serialize) data = serialize(data); } // content-length if (data && !req.getHeader('Content-Length')) { req.setHeader('Content-Length', Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data)); } } // response req.once('response', function(res){ debug('%s %s -> %s', self.method, self.url, res.statusCode); if (self._responseTimeoutTimer) { clearTimeout(self._responseTimeoutTimer); } if (self.piped) { return; } var max = self._maxRedirects; var mime = utils.type(res.headers['content-type'] || '') || 'text/plain'; var type = mime.split('/')[0]; var multipart = 'multipart' == type; var redirect = isRedirect(res.statusCode); var parser = self._parser; self.res = res; // redirect if (redirect && self._redirects++ != max) { return self._redirect(res); } if ('HEAD' == self.method) { self.emit('end'); self.callback(null, self._emitResponse()); return; } // zlib support if (self._shouldUnzip(res)) { unzip(req, res); } if (!parser) { if (this._responseType) { parser = exports.parse.image; // It's actually a generic Buffer buffer = true; } else if (multipart) { var form = new formidable.IncomingForm(); parser = form.parse.bind(form); buffer = true; } else if (isImageOrVideo(mime)) { parser = exports.parse.image; buffer = true; // For backwards-compatibility buffering default is ad-hoc MIME-dependent } else if (exports.parse[mime]) { parser = exports.parse[mime]; } else if ('text' == type) { parser = exports.parse.text; buffer = (buffer !== false); // everyone wants their own white-labeled json } else if (isJSON(mime)) { parser = exports.parse['application/json']; buffer = (buffer !== false); } else if (buffer) { parser = exports.parse.text; } } // by default only buffer text/*, json and messed up thing from hell if (undefined === buffer && isText(mime) || isJSON(mime)) { buffer = true; } var parserHandlesEnd = false; if (parser) { try { // Unbuffered parsers are supposed to emit response early, // which is weird BTW, because response.body won't be there. parserHandlesEnd = buffer; parser(res, function(err, obj, files) { if (self.timedout) { // Timeout has already handled all callbacks return; } // Intentional (non-timeout) abort is supposed to preserve partial response, // even if it doesn't parse. if (err && !self._aborted) { return self.callback(err); } if (parserHandlesEnd) { self.emit('end'); self.callback(null, self._emitResponse(obj, files)); } }); } catch (err) { self.callback(err); return; } } self.res = res; // unbuffered if (!buffer) { debug('unbuffered %s %s', self.method, self.url); self.callback(null, self._emitResponse()); if (multipart) return // allow multipart to handle end event res.once('end', function(){ debug('end %s %s', self.method, self.url); self.emit('end'); }) return; } // terminating events res.once('error', function(err){ self.callback(err, null); }); if (!parserHandlesEnd) res.once('end', function(){ debug('end %s %s', self.method, self.url); // TODO: unless buffering emit earlier to stream self.emit('end'); self.callback(null, self._emitResponse()); }); }); this.emit('request', this); // if a FormData instance got created, then we send that as the request body var formData = this._formData; if (formData) { // set headers var headers = formData.getHeaders(); for (var i in headers) { debug('setting FormData header: "%s: %s"', i, headers[i]); req.setHeader(i, headers[i]); } // attempt to get "Content-Length" header formData.getLength(function(err, length) { // TODO: Add chunked encoding when no length (if err) debug('got FormData Content-Length: %s', length); if ('number' == typeof length) { req.setHeader('Content-Length', length); } var getProgressMonitor = function () { var lengthComputable = true; var total = req.getHeader('Content-Length'); var loaded = 0; var progress = new Stream.Transform(); progress._transform = function (chunk, encoding, cb) { loaded += chunk.length; self.emit('progress', { direction: 'upload', lengthComputable: lengthComputable, loaded: loaded, total: total }); cb(null, chunk); }; return progress; }; formData.pipe(getProgressMonitor()).pipe(req); }); } else { req.end(data); } return this; }; /** * Check whether response has a non-0-sized gzip-encoded body */ Request.prototype._shouldUnzip = function(res){ if (res.statusCode === 204 || res.statusCode === 304) { // These aren't supposed to have any body return false; } // header content is a string, and distinction between 0 and no information is crucial if ('0' === res.headers['content-length']) { // We know that the body is empty (unfortunately, this check does not cover chunked encoding) return false; } // console.log(res); return /^\s*(?:deflate|gzip)\s*$/.test(res.headers['content-encoding']); }; // generate HTTP verb methods if (methods.indexOf('del') == -1) { // create a copy so we don't cause conflicts with // other packages using the methods package and // npm 3.x methods = methods.slice(0); methods.push('del'); } methods.forEach(function(method){ var name = method; method = 'del' == method ? 'delete' : method; method = method.toUpperCase(); request[name] = function(url, data, fn){ var req = request(method, url); if ('function' == typeof data) fn = data, data = null; if (data) req.send(data); 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 || 'x-www-form-urlencoded' == subtype; } function isImageOrVideo(mime) { var type = mime.split('/')[0]; return 'image' == type || 'video' == type; } /** * Check if `mime` is json or has +json structured syntax suffix. * * @param {String} mime * @return {Boolean} * @api private */ function isJSON(mime) { return /[\/+]json\b/.test(mime); } /** * Check if we should follow the redirect `code`. * * @param {Number} code * @return {Boolean} * @api private */ function isRedirect(code) { return ~[301, 302, 303, 305, 307, 308].indexOf(code); }