UNPKG

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