Files
bug-em/node_modules/twit/lib/twitter.js
Talor Berthelson 889faf9c1c lol
2016-06-17 20:49:15 -04:00

486 lines
15 KiB
JavaScript

//
// Twitter API Wrapper
//
var assert = require('assert');
var Promise = require('bluebird');
var request = require('request');
var util = require('util');
var endpoints = require('./endpoints');
var FileUploader = require('./file_uploader');
var helpers = require('./helpers');
var StreamingAPIConnection = require('./streaming-api-connection');
var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON;
// config values required for app-only auth
var required_for_app_auth = [
'consumer_key',
'consumer_secret'
];
// config values required for user auth (superset of app-only auth)
var required_for_user_auth = required_for_app_auth.concat([
'access_token',
'access_token_secret'
]);
var FORMDATA_PATHS = [
'media/upload',
'account/update_profile_image',
'account/update_profile_background_image',
];
var JSONPAYLOAD_PATHS = [
'media/metadata/create'
]
//
// Twitter
//
var Twitter = function (config) {
if (!(this instanceof Twitter)) {
return new Twitter(config);
}
var self = this
var credentials = {
consumer_key : config.consumer_key,
consumer_secret : config.consumer_secret,
// access_token and access_token_secret only required for user auth
access_token : config.access_token,
access_token_secret : config.access_token_secret,
// flag indicating whether requests should be made with application-only auth
app_only_auth : config.app_only_auth,
}
this._validateConfigOrThrow(config);
this.config = config;
this._twitter_time_minus_local_time_ms = 0;
}
Twitter.prototype.get = function (path, params, callback) {
return this.request('GET', path, params, callback)
}
Twitter.prototype.post = function (path, params, callback) {
return this.request('POST', path, params, callback)
}
Twitter.prototype.request = function (method, path, params, callback) {
var self = this;
assert(method == 'GET' || method == 'POST');
// if no `params` is specified but a callback is, use default params
if (typeof params === 'function') {
callback = params
params = {}
}
return new Promise(function (resolve, reject) {
var _returnErrorToUser = function (err) {
if (callback && typeof callback === 'function') {
callback(err, null, null);
}
reject(err);
}
self._buildReqOpts(method, path, params, false, function (err, reqOpts) {
if (err) {
_returnErrorToUser(err);
return
}
var twitOptions = (params && params.twit_options) || {};
process.nextTick(function () {
// ensure all HTTP i/o occurs after the user has a chance to bind their event handlers
self._doRestApiRequest(reqOpts, twitOptions, method, function (err, parsedBody, resp) {
self._updateClockOffsetFromResponse(resp);
if (self.config.trusted_cert_fingerprints) {
if (!resp.socket.authorized) {
// The peer certificate was not signed by one of the authorized CA's.
var authErrMsg = resp.socket.authorizationError.toString();
var err = helpers.makeTwitError('The peer certificate was not signed; ' + authErrMsg);
_returnErrorToUser(err);
return;
}
var fingerprint = resp.socket.getPeerCertificate().fingerprint;
var trustedFingerprints = self.config.trusted_cert_fingerprints;
if (trustedFingerprints.indexOf(fingerprint) === -1) {
var errMsg = util.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.',
trustedFingerprints.join(','), fingerprint);
var err = new Error(errMsg);
_returnErrorToUser(err);
return;
}
}
if (callback && typeof callback === 'function') {
callback(err, parsedBody, resp);
}
resolve({ data: parsedBody, resp: resp });
return;
})
})
});
});
}
/**
* Uploads a file to Twitter via the POST media/upload (chunked) API.
* Use this as an easier alternative to doing the INIT/APPEND/FINALIZE commands yourself.
* Returns the response from the FINALIZE command, or if an error occurs along the way,
* the first argument to `cb` will be populated with a non-null Error.
*
*
* `params` is an Object of the form:
* {
* file_path: String // Absolute path of file to be uploaded.
* }
*
* @param {Object} params options object (described above).
* @param {cb} cb callback of the form: function (err, bodyObj, resp)
*/
Twitter.prototype.postMediaChunked = function (params, cb) {
var self = this;
try {
var fileUploader = new FileUploader(params, self);
} catch(err) {
cb(err);
return;
}
fileUploader.upload(cb);
}
Twitter.prototype._updateClockOffsetFromResponse = function (resp) {
var self = this;
if (resp && resp.headers && resp.headers.date &&
new Date(resp.headers.date).toString() !== 'Invalid Date'
) {
var twitterTimeMs = new Date(resp.headers.date).getTime()
self._twitter_time_minus_local_time_ms = twitterTimeMs - Date.now();
}
}
/**
* Builds and returns an options object ready to pass to `request()`
* @param {String} method "GET" or "POST"
* @param {String} path REST API resource uri (eg. "statuses/destroy/:id")
* @param {Object} params user's params object
* @param {Boolean} isStreaming Flag indicating if it's a request to the Streaming API (different endpoint)
* @returns {Undefined}
*
* Calls `callback` with Error, Object where Object is an options object ready to pass to `request()`.
*
* Returns error raised (if any) by `helpers.moveParamsIntoPath()`
*/
Twitter.prototype._buildReqOpts = function (method, path, params, isStreaming, callback) {
var self = this
if (!params) {
params = {}
}
// clone `params` object so we can modify it without modifying the user's reference
var paramsClone = JSON.parse(JSON.stringify(params))
// convert any arrays in `paramsClone` to comma-seperated strings
var finalParams = this.normalizeParams(paramsClone)
delete finalParams.twit_options
// the options object passed to `request` used to perform the HTTP request
var reqOpts = {
headers: {
'Accept': '*/*',
'User-Agent': 'twit-client'
},
gzip: true,
encoding: null,
}
if (typeof self.config.timeout_ms !== 'undefined') {
reqOpts.timeout = self.config.timeout_ms;
}
try {
// finalize the `path` value by building it using user-supplied params
path = helpers.moveParamsIntoPath(finalParams, path)
} catch (e) {
callback(e, null, null)
return
}
if (isStreaming) {
// This is a Streaming API request.
var stream_endpoint_map = {
user: endpoints.USER_STREAM,
site: endpoints.SITE_STREAM
}
var endpoint = stream_endpoint_map[path] || endpoints.PUB_STREAM
reqOpts.url = endpoint + path + '.json'
} else {
// This is a REST API request.
if (path.indexOf('media/') !== -1) {
// For media/upload, use a different endpoint.
reqOpts.url = endpoints.MEDIA_UPLOAD + path + '.json';
} else {
reqOpts.url = endpoints.REST_ROOT + path + '.json';
}
if (FORMDATA_PATHS.indexOf(path) !== -1) {
reqOpts.headers['Content-type'] = 'multipart/form-data';
reqOpts.form = finalParams;
// set finalParams to empty object so we don't append a query string
// of the params
finalParams = {};
} else if (JSONPAYLOAD_PATHS.indexOf(path) !== -1) {
reqOpts.headers['Content-type'] = 'application/json';
reqOpts.json = true;
reqOpts.body = finalParams;
// as above, to avoid appending query string for body params
finalParams = {};
} else {
reqOpts.headers['Content-type'] = 'application/json';
}
}
if (Object.keys(finalParams).length) {
// not all of the user's parameters were used to build the request path
// add them as a query string
var qs = helpers.makeQueryString(finalParams)
reqOpts.url += '?' + qs
}
if (!self.config.app_only_auth) {
// with user auth, we can just pass an oauth object to requests
// to have the request signed
var oauth_ts = Date.now() + self._twitter_time_minus_local_time_ms;
reqOpts.oauth = {
consumer_key: self.config.consumer_key,
consumer_secret: self.config.consumer_secret,
token: self.config.access_token,
token_secret: self.config.access_token_secret,
timestamp: Math.floor(oauth_ts/1000).toString(),
}
callback(null, reqOpts);
return;
} else {
// we're using app-only auth, so we need to ensure we have a bearer token
// Once we have a bearer token, add the Authorization header and return the fully qualified `reqOpts`.
self._getBearerToken(function (err, bearerToken) {
if (err) {
callback(err, null)
return
}
reqOpts.headers['Authorization'] = 'Bearer ' + bearerToken;
callback(null, reqOpts)
return
})
}
}
/**
* Make HTTP request to Twitter REST API.
* @param {Object} reqOpts options object passed to `request()`
* @param {Object} twitOptions
* @param {String} method "GET" or "POST"
* @param {Function} callback user's callback
* @return {Undefined}
*/
Twitter.prototype._doRestApiRequest = function (reqOpts, twitOptions, method, callback) {
var request_method = request[method.toLowerCase()];
var req = request_method(reqOpts);
var body = '';
var response = null;
var onRequestComplete = function () {
if (body !== '') {
try {
body = JSON.parse(body)
} catch (jsonDecodeError) {
// there was no transport-level error, but a JSON object could not be decoded from the request body
// surface this to the caller
var err = helpers.makeTwitError('JSON decode error: Twitter HTTP response body was not valid JSON')
err.statusCode = response ? response.statusCode: null;
err.allErrors.concat({error: jsonDecodeError.toString()})
callback(err, body, response);
return
}
}
if (typeof body === 'object' && (body.error || body.errors)) {
// we got a Twitter API-level error response
// place the errors in the HTTP response body into the Error object and pass control to caller
var err = helpers.makeTwitError('Twitter API Error')
err.statusCode = response ? response.statusCode: null;
helpers.attachBodyInfoToError(err, body);
callback(err, body, response);
return
}
// success case - no errors in HTTP response body
callback(err, body, response)
}
req.on('response', function (res) {
response = res
// read data from `request` object which contains the decompressed HTTP response body,
// `response` is the unmodified http.IncomingMessage object which may contain compressed data
req.on('data', function (chunk) {
body += chunk.toString('utf8')
})
// we're done reading the response
req.on('end', function () {
onRequestComplete()
})
})
req.on('error', function (err) {
// transport-level error occurred - likely a socket error
if (twitOptions.retry &&
STATUS_CODES_TO_ABORT_ON.indexOf(err.statusCode) !== -1
) {
// retry the request since retries were specified and we got a status code we should retry on
self.request(method, path, params, callback);
return;
} else {
// pass the transport-level error to the caller
err.statusCode = null
err.code = null
err.allErrors = [];
helpers.attachBodyInfoToError(err, body)
callback(err, body, response);
return;
}
})
}
/**
* Creates/starts a connection object that stays connected to Twitter's servers
* using Twitter's rules.
*
* @param {String} path Resource path to connect to (eg. "statuses/sample")
* @param {Object} params user's params object
* @return {StreamingAPIConnection} [description]
*/
Twitter.prototype.stream = function (path, params) {
var self = this;
var twitOptions = (params && params.twit_options) || {};
var streamingConnection = new StreamingAPIConnection()
self._buildReqOpts('POST', path, params, true, function (err, reqOpts) {
if (err) {
// we can get an error if we fail to obtain a bearer token or construct reqOpts
// surface this on the streamingConnection instance (where a user may register their error handler)
streamingConnection.emit('error', err)
return
}
// set the properties required to start the connection
streamingConnection.reqOpts = reqOpts
streamingConnection.twitOptions = twitOptions
process.nextTick(function () {
streamingConnection.start()
})
})
return streamingConnection
}
/**
* Gets bearer token from cached reference on `self`, or fetches a new one and sets it on `self`.
*
* @param {Function} callback Function to invoke with (Error, bearerToken)
* @return {Undefined}
*/
Twitter.prototype._getBearerToken = function (callback) {
var self = this;
if (self._bearerToken) {
return callback(null, self._bearerToken)
}
helpers.getBearerToken(self.config.consumer_key, self.config.consumer_secret,
function (err, bearerToken) {
if (err) {
// return the fully-qualified Twit Error object to caller
callback(err, null);
return;
}
self._bearerToken = bearerToken;
callback(null, self._bearerToken);
return;
})
}
Twitter.prototype.normalizeParams = function (params) {
var normalized = params
if (params && typeof params === 'object') {
Object.keys(params).forEach(function (key) {
var value = params[key]
// replace any arrays in `params` with comma-separated string
if (Array.isArray(value))
normalized[key] = value.join(',')
})
} else if (!params) {
normalized = {}
}
return normalized
}
Twitter.prototype.setAuth = function (auth) {
var self = this
var configKeys = [
'consumer_key',
'consumer_secret',
'access_token',
'access_token_secret'
];
// update config
configKeys.forEach(function (k) {
if (auth[k]) {
self.config[k] = auth[k]
}
})
this._validateConfigOrThrow(self.config);
}
Twitter.prototype.getAuth = function () {
return this.config
}
//
// Check that the required auth credentials are present in `config`.
// @param {Object} config Object containing credentials for REST API auth
//
Twitter.prototype._validateConfigOrThrow = function (config) {
//check config for proper format
if (typeof config !== 'object') {
throw new TypeError('config must be object, got ' + typeof config)
}
if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) {
throw new TypeError('Twit config `timeout_ms` must be a Number. Got: ' + config.timeout_ms + '.');
}
if (config.app_only_auth) {
var auth_type = 'app-only auth'
var required_keys = required_for_app_auth
} else {
var auth_type = 'user auth'
var required_keys = required_for_user_auth
}
required_keys.forEach(function (req_key) {
if (!config[req_key]) {
var err_msg = util.format('Twit config must include `%s` when using %s.', req_key, auth_type)
throw new Error(err_msg)
}
})
}
module.exports = Twitter