twitst4tz

twitter statistics web application
Log | Files | Refs | README | LICENSE

twitter.js (16533B)


      1 //
      2 //  Twitter API Wrapper
      3 //
      4 var assert = require('assert');
      5 var Promise = require('bluebird');
      6 var request = require('request');
      7 var util = require('util');
      8 var endpoints = require('./endpoints');
      9 var FileUploader = require('./file_uploader');
     10 var helpers = require('./helpers');
     11 var StreamingAPIConnection = require('./streaming-api-connection');
     12 var STATUS_CODES_TO_ABORT_ON = require('./settings').STATUS_CODES_TO_ABORT_ON;
     13 
     14 // config values required for app-only auth
     15 var required_for_app_auth = [
     16   'consumer_key',
     17   'consumer_secret'
     18 ];
     19 
     20 // config values required for user auth (superset of app-only auth)
     21 var required_for_user_auth = required_for_app_auth.concat([
     22   'access_token',
     23   'access_token_secret'
     24 ]);
     25 
     26 var FORMDATA_PATHS = [
     27   'media/upload',
     28   'account/update_profile_image',
     29   'account/update_profile_background_image',
     30 ];
     31 
     32 var JSONPAYLOAD_PATHS = [
     33   'media/metadata/create',
     34   'direct_messages/events/new',
     35   'direct_messages/welcome_messages/new',
     36   'direct_messages/welcome_messages/rules/new',
     37 ];
     38 
     39 //
     40 //  Twitter
     41 //
     42 var Twitter = function (config) {
     43   if (!(this instanceof Twitter)) {
     44     return new Twitter(config);
     45   }
     46 
     47   var self = this
     48   var credentials = {
     49     consumer_key        : config.consumer_key,
     50     consumer_secret     : config.consumer_secret,
     51     // access_token and access_token_secret only required for user auth
     52     access_token        : config.access_token,
     53     access_token_secret : config.access_token_secret,
     54     // flag indicating whether requests should be made with application-only auth
     55     app_only_auth       : config.app_only_auth,
     56   }
     57 
     58   this._validateConfigOrThrow(config);
     59   this.config = config;
     60   this._twitter_time_minus_local_time_ms = 0;
     61 }
     62 
     63 Twitter.prototype.get = function (path, params, callback) {
     64   return this.request('GET', path, params, callback)
     65 }
     66 
     67 Twitter.prototype.post = function (path, params, callback) {
     68   return this.request('POST', path, params, callback)
     69 }
     70 
     71 Twitter.prototype.delete = function (path, params, callback) {
     72   return this.request('DELETE', path, params, callback)
     73 }
     74 
     75 Twitter.prototype.request = function (method, path, params, callback) {
     76   var self = this;
     77   assert(method == 'GET' || method == 'POST' || method == 'DELETE');
     78   // if no `params` is specified but a callback is, use default params
     79   if (typeof params === 'function') {
     80     callback = params
     81     params = {}
     82   }
     83 
     84   return new Promise(function (resolve, reject) {
     85     var _returnErrorToUser = function (err) {
     86       if (callback && typeof callback === 'function') {
     87         callback(err, null, null);
     88       } else {
     89         reject(err);
     90       }
     91     }
     92 
     93     self._buildReqOpts(method, path, params, false, function (err, reqOpts) {
     94       if (err) {
     95         _returnErrorToUser(err);
     96         return
     97       }
     98 
     99       var twitOptions = (params && params.twit_options) || {};
    100 
    101       process.nextTick(function () {
    102         // ensure all HTTP i/o occurs after the user has a chance to bind their event handlers
    103         self._doRestApiRequest(reqOpts, twitOptions, method, function (err, parsedBody, resp) {
    104           self._updateClockOffsetFromResponse(resp);
    105           var peerCertificate = resp && resp.socket && resp.socket.getPeerCertificate();
    106 
    107           if (self.config.trusted_cert_fingerprints && peerCertificate) {
    108             if (!resp.socket.authorized) {
    109               // The peer certificate was not signed by one of the authorized CA's.
    110               var authErrMsg = resp.socket.authorizationError.toString();
    111               var err = helpers.makeTwitError('The peer certificate was not signed; ' + authErrMsg);
    112               _returnErrorToUser(err);
    113               return;
    114             }
    115             var fingerprint = peerCertificate.fingerprint;
    116             var trustedFingerprints = self.config.trusted_cert_fingerprints;
    117             if (trustedFingerprints.indexOf(fingerprint) === -1) {
    118               var errMsg = util.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.',
    119                                        trustedFingerprints.join(','), fingerprint);
    120               var err = new Error(errMsg);
    121               _returnErrorToUser(err);
    122               return;
    123             }
    124           }
    125 
    126           if (callback && typeof callback === 'function') {
    127             callback(err, parsedBody, resp);
    128           } else {
    129             if (err) {
    130               reject(err)
    131             } else {
    132               resolve({ data: parsedBody, resp: resp });
    133             }
    134           }
    135 
    136           return;
    137         })
    138       })
    139     });
    140   });
    141 }
    142 
    143 /**
    144  * Uploads a file to Twitter via the POST media/upload (chunked) API.
    145  * Use this as an easier alternative to doing the INIT/APPEND/FINALIZE commands yourself.
    146  * Returns the response from the FINALIZE command, or if an error occurs along the way,
    147  * the first argument to `cb` will be populated with a non-null Error.
    148  *
    149  *
    150  * `params` is an Object of the form:
    151  * {
    152  *   file_path: String // Absolute path of file to be uploaded.
    153  * }
    154  *
    155  * @param  {Object}  params  options object (described above).
    156  * @param  {cb}      cb      callback of the form: function (err, bodyObj, resp)
    157  */
    158 Twitter.prototype.postMediaChunked = function (params, cb) {
    159   var self = this;
    160   try {
    161     var fileUploader = new FileUploader(params, self);
    162   } catch(err) {
    163     cb(err);
    164     return;
    165   }
    166   fileUploader.upload(cb);
    167 }
    168 
    169 Twitter.prototype._updateClockOffsetFromResponse = function (resp) {
    170   var self = this;
    171   if (resp && resp.headers && resp.headers.date &&
    172       new Date(resp.headers.date).toString() !== 'Invalid Date'
    173   ) {
    174     var twitterTimeMs = new Date(resp.headers.date).getTime()
    175     self._twitter_time_minus_local_time_ms = twitterTimeMs - Date.now();
    176   }
    177 }
    178 
    179 /**
    180  * Builds and returns an options object ready to pass to `request()`
    181  * @param  {String}   method      "GET" or "POST"
    182  * @param  {String}   path        REST API resource uri (eg. "statuses/destroy/:id")
    183  * @param  {Object}   params      user's params object
    184  * @param  {Boolean}  isStreaming Flag indicating if it's a request to the Streaming API (different endpoint)
    185  * @returns {Undefined}
    186  *
    187  * Calls `callback` with Error, Object where Object is an options object ready to pass to `request()`.
    188  *
    189  * Returns error raised (if any) by `helpers.moveParamsIntoPath()`
    190  */
    191 Twitter.prototype._buildReqOpts = function (method, path, params, isStreaming, callback) {
    192   var self = this
    193   if (!params) {
    194     params = {}
    195   }
    196   // clone `params` object so we can modify it without modifying the user's reference
    197   var paramsClone = JSON.parse(JSON.stringify(params))
    198   // convert any arrays in `paramsClone` to comma-seperated strings
    199   var finalParams = this.normalizeParams(paramsClone)
    200   delete finalParams.twit_options
    201 
    202   // the options object passed to `request` used to perform the HTTP request
    203   var reqOpts = {
    204     headers: {
    205       'Accept': '*/*',
    206       'User-Agent': 'twit-client'
    207     },
    208     gzip: true,
    209     encoding: null,
    210   }
    211 
    212   if (typeof self.config.timeout_ms !== 'undefined' && !isStreaming) {
    213     reqOpts.timeout = self.config.timeout_ms;
    214   }
    215 
    216   if (typeof self.config.strictSSL !== 'undefined') {
    217     reqOpts.strictSSL = self.config.strictSSL;
    218   }
    219   
    220   // finalize the `path` value by building it using user-supplied params
    221   // when json parameters should not be in the payload
    222   if (JSONPAYLOAD_PATHS.indexOf(path) === -1) {
    223     try {
    224       path = helpers.moveParamsIntoPath(finalParams, path)
    225     } catch (e) {
    226       callback(e, null, null)
    227       return
    228     }
    229   }
    230 
    231   if (path.match(/^https?:\/\//i)) {
    232     // This is a full url request
    233     reqOpts.url = path
    234   } else
    235   if (isStreaming) {
    236     // This is a Streaming API request.
    237 
    238     var stream_endpoint_map = {
    239       user: endpoints.USER_STREAM,
    240       site: endpoints.SITE_STREAM
    241     }
    242     var endpoint = stream_endpoint_map[path] || endpoints.PUB_STREAM
    243     reqOpts.url = endpoint + path + '.json'
    244   } else {
    245     // This is a REST API request.
    246 
    247     if (path.indexOf('media/') !== -1) {
    248       // For media/upload, use a different endpoint.
    249       reqOpts.url = endpoints.MEDIA_UPLOAD + path + '.json';
    250     } else {
    251       reqOpts.url = endpoints.REST_ROOT + path + '.json';
    252     }
    253 
    254     if (FORMDATA_PATHS.indexOf(path) !== -1) {
    255       reqOpts.headers['Content-type'] = 'multipart/form-data';
    256       reqOpts.form = finalParams;
    257        // set finalParams to empty object so we don't append a query string
    258       // of the params
    259       finalParams = {};
    260     } else if (JSONPAYLOAD_PATHS.indexOf(path) !== -1) {
    261       reqOpts.headers['Content-type'] = 'application/json';
    262       reqOpts.json = true;
    263       reqOpts.body = finalParams;
    264       // as above, to avoid appending query string for body params
    265       finalParams = {};
    266     } else {
    267       reqOpts.headers['Content-type'] = 'application/json';
    268     }
    269   }
    270 
    271   if (isStreaming) {
    272     reqOpts.form = finalParams
    273   } else if (Object.keys(finalParams).length) {
    274     // not all of the user's parameters were used to build the request path
    275     // add them as a query string
    276     var qs = helpers.makeQueryString(finalParams)
    277     reqOpts.url += '?' + qs
    278   }
    279 
    280   if (!self.config.app_only_auth) {
    281     // with user auth, we can just pass an oauth object to requests
    282     // to have the request signed
    283     var oauth_ts = Date.now() + self._twitter_time_minus_local_time_ms;
    284 
    285     reqOpts.oauth = {
    286       consumer_key: self.config.consumer_key,
    287       consumer_secret: self.config.consumer_secret,
    288       token: self.config.access_token,
    289       token_secret: self.config.access_token_secret,
    290       timestamp: Math.floor(oauth_ts/1000).toString(),
    291     }
    292 
    293     callback(null, reqOpts);
    294     return;
    295   } else {
    296     // we're using app-only auth, so we need to ensure we have a bearer token
    297     // Once we have a bearer token, add the Authorization header and return the fully qualified `reqOpts`.
    298     self._getBearerToken(function (err, bearerToken) {
    299       if (err) {
    300         callback(err, null)
    301         return
    302       }
    303 
    304       reqOpts.headers['Authorization'] = 'Bearer ' + bearerToken;
    305       callback(null, reqOpts)
    306       return
    307     })
    308   }
    309 }
    310 
    311 /**
    312  * Make HTTP request to Twitter REST API.
    313  * @param  {Object}   reqOpts     options object passed to `request()`
    314  * @param  {Object}   twitOptions
    315  * @param  {String}   method      "GET" or "POST"
    316  * @param  {Function} callback    user's callback
    317  * @return {Undefined}
    318  */
    319 Twitter.prototype._doRestApiRequest = function (reqOpts, twitOptions, method, callback) {
    320   var request_method = request[method.toLowerCase()];
    321   var req = request_method(reqOpts);
    322 
    323   var body = '';
    324   var response = null;
    325 
    326   var onRequestComplete = function () {
    327     if (body !== '') {
    328       try {
    329         body = JSON.parse(body)
    330       } catch (jsonDecodeError) {
    331         // there was no transport-level error, but a JSON object could not be decoded from the request body
    332         // surface this to the caller
    333         var err = helpers.makeTwitError('JSON decode error: Twitter HTTP response body was not valid JSON')
    334         err.statusCode = response ? response.statusCode: null;
    335         err.allErrors.concat({error: jsonDecodeError.toString()})
    336         callback(err, body, response);
    337         return
    338       }
    339     }
    340 
    341     if (typeof body === 'object' && (body.error || body.errors)) {
    342       // we got a Twitter API-level error response
    343       // place the errors in the HTTP response body into the Error object and pass control to caller
    344       var err = helpers.makeTwitError('Twitter API Error')
    345       err.statusCode = response ? response.statusCode: null;
    346       helpers.attachBodyInfoToError(err, body);
    347       callback(err, body, response);
    348       return
    349     }
    350 
    351     // success case - no errors in HTTP response body
    352     callback(err, body, response)
    353   }
    354 
    355   req.on('response', function (res) {
    356     response = res
    357     // read data from `request` object which contains the decompressed HTTP response body,
    358     // `response` is the unmodified http.IncomingMessage object which may contain compressed data
    359     req.on('data', function (chunk) {
    360       body += chunk.toString('utf8')
    361     })
    362     // we're done reading the response
    363     req.on('end', function () {
    364       onRequestComplete()
    365     })
    366   })
    367 
    368   req.on('error', function (err) {
    369     // transport-level error occurred - likely a socket error
    370     if (twitOptions.retry &&
    371         STATUS_CODES_TO_ABORT_ON.indexOf(err.statusCode) !== -1
    372     ) {
    373       // retry the request since retries were specified and we got a status code we should retry on
    374       self.request(method, path, params, callback);
    375       return;
    376     } else {
    377       // pass the transport-level error to the caller
    378       err.statusCode = null
    379       err.code = null
    380       err.allErrors = [];
    381       helpers.attachBodyInfoToError(err, body)
    382       callback(err, body, response);
    383       return;
    384     }
    385   })
    386 }
    387 
    388 /**
    389  * Creates/starts a connection object that stays connected to Twitter's servers
    390  * using Twitter's rules.
    391  *
    392  * @param  {String} path   Resource path to connect to (eg. "statuses/sample")
    393  * @param  {Object} params user's params object
    394  * @return {StreamingAPIConnection}        [description]
    395  */
    396 Twitter.prototype.stream = function (path, params) {
    397   var self = this;
    398   var twitOptions = (params && params.twit_options) || {};
    399 
    400   var streamingConnection = new StreamingAPIConnection()
    401   self._buildReqOpts('POST', path, params, true, function (err, reqOpts) {
    402     if (err) {
    403       // we can get an error if we fail to obtain a bearer token or construct reqOpts
    404       // surface this on the streamingConnection instance (where a user may register their error handler)
    405       streamingConnection.emit('error', err)
    406       return
    407     }
    408     // set the properties required to start the connection
    409     streamingConnection.reqOpts = reqOpts
    410     streamingConnection.twitOptions = twitOptions
    411 
    412     process.nextTick(function () {
    413       streamingConnection.start()
    414     })
    415   })
    416 
    417   return streamingConnection
    418 }
    419 
    420 /**
    421  * Gets bearer token from cached reference on `self`, or fetches a new one and sets it on `self`.
    422  *
    423  * @param  {Function} callback Function to invoke with (Error, bearerToken)
    424  * @return {Undefined}
    425  */
    426 Twitter.prototype._getBearerToken = function (callback) {
    427   var self = this;
    428   if (self._bearerToken) {
    429     return callback(null, self._bearerToken)
    430   }
    431 
    432   helpers.getBearerToken(self.config.consumer_key, self.config.consumer_secret,
    433   function (err, bearerToken) {
    434     if (err) {
    435       // return the fully-qualified Twit Error object to caller
    436       callback(err, null);
    437       return;
    438     }
    439     self._bearerToken = bearerToken;
    440     callback(null, self._bearerToken);
    441     return;
    442   })
    443 }
    444 
    445 Twitter.prototype.normalizeParams = function (params) {
    446   var normalized = params
    447   if (params && typeof params === 'object') {
    448     Object.keys(params).forEach(function (key) {
    449       var value = params[key]
    450       // replace any arrays in `params` with comma-separated string
    451       if (Array.isArray(value))
    452         normalized[key] = value.join(',')
    453     })
    454   } else if (!params) {
    455     normalized = {}
    456   }
    457   return normalized
    458 }
    459 
    460 Twitter.prototype.setAuth = function (auth) {
    461   var self = this
    462   var configKeys = [
    463     'consumer_key',
    464     'consumer_secret',
    465     'access_token',
    466     'access_token_secret'
    467   ];
    468 
    469   // update config
    470   configKeys.forEach(function (k) {
    471     if (auth[k]) {
    472       self.config[k] = auth[k]
    473     }
    474   })
    475   this._validateConfigOrThrow(self.config);
    476 }
    477 
    478 Twitter.prototype.getAuth = function () {
    479   return this.config
    480 }
    481 
    482 //
    483 // Check that the required auth credentials are present in `config`.
    484 // @param {Object}  config  Object containing credentials for REST API auth
    485 //
    486 Twitter.prototype._validateConfigOrThrow = function (config) {
    487   //check config for proper format
    488   if (typeof config !== 'object') {
    489     throw new TypeError('config must be object, got ' + typeof config)
    490   }
    491 
    492   if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) {
    493     throw new TypeError('Twit config `timeout_ms` must be a Number. Got: ' + config.timeout_ms + '.');
    494   }
    495 
    496   if (typeof config.strictSSL !== 'undefined' && typeof config.strictSSL !== 'boolean') {
    497     throw new TypeError('Twit config `strictSSL` must be a Boolean. Got: ' + config.strictSSL + '.');
    498   }
    499 
    500   if (config.app_only_auth) {
    501     var auth_type = 'app-only auth'
    502     var required_keys = required_for_app_auth
    503   } else {
    504     var auth_type = 'user auth'
    505     var required_keys = required_for_user_auth
    506   }
    507 
    508   required_keys.forEach(function (req_key) {
    509     if (!config[req_key]) {
    510       var err_msg = util.format('Twit config must include `%s` when using %s.', req_key, auth_type)
    511       throw new Error(err_msg)
    512     }
    513   })
    514 }
    515 
    516 module.exports = Twitter