UNPKG

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