/
FetcherService.ts
365 lines (311 loc) · 10.9 KB
/
FetcherService.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
// PACKAGES
import {
Request,
FetchArgs,
PostArgs,
EResourceType,
ICursor as IRawCursor,
ITweet as IRawTweet,
IUser as IRawUser,
ITimelineTweet,
ITimelineUser,
IResponse,
EUploadSteps,
IMediaUploadInitializeResponse,
} from 'rettiwt-core';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import https, { Agent } from 'https';
import { AuthCredential, Auth } from 'rettiwt-auth';
import { HttpsProxyAgent } from 'https-proxy-agent';
// SERVICES
import { ErrorService } from './ErrorService';
import { LogService } from './LogService';
// TYPES
import { IRettiwtConfig } from '../../types/RettiwtConfig';
import { IErrorHandler } from '../../types/ErrorHandler';
// ENUMS
import { EApiErrors } from '../../enums/Api';
import { ELogActions } from '../../enums/Logging';
// MODELS
import { CursoredData } from '../../models/data/CursoredData';
import { Tweet } from '../../models/data/Tweet';
import { User } from '../../models/data/User';
// HELPERS
import { findByFilter } from '../../helper/JsonUtils';
import { statSync } from 'fs';
/**
* The base service that handles all HTTP requests.
*
* @internal
*/
export class FetcherService {
/** The credential to use for authenticating against Twitter API. */
private cred?: AuthCredential;
/** Whether the instance is authenticated or not. */
private readonly isAuthenticated: boolean;
/** The URL to the proxy server to use for authentication. */
protected readonly authProxyUrl?: URL;
/** The HTTPS Agent to use for requests to Twitter API. */
private readonly httpsAgent: Agent;
/** The max wait time for a response. */
private readonly timeout: number;
/** The log service instance to use to logging. */
private readonly logger: LogService;
/** The service used to handle HTTP and API errors */
private readonly errorHandler: IErrorHandler;
/**
* @param config - The config object for configuring the Rettiwt instance.
*/
public constructor(config?: IRettiwtConfig) {
// If API key is supplied
if (config?.apiKey) {
this.cred = this.getAuthCredential(config.apiKey);
}
// If guest key is supplied
else if (config?.guestKey) {
this.cred = this.getGuestCredential(config.guestKey);
}
// If no key is supplied
else {
this.cred = undefined;
}
this.isAuthenticated = config?.apiKey ? true : false;
this.authProxyUrl = config?.authProxyUrl ?? config?.proxyUrl;
this.httpsAgent = this.getHttpsAgent(config?.proxyUrl);
this.timeout = config?.timeout ?? 0;
this.logger = new LogService(config?.logging);
this.errorHandler = config?.errorHandler ?? new ErrorService();
}
/**
* Returns an AuthCredential generated using the given API key.
*
* @param apiKey - The API key to use for authenticating.
* @returns The generated AuthCredential.
*/
private getAuthCredential(apiKey: string): AuthCredential {
// Converting apiKey from base64 to string
apiKey = Buffer.from(apiKey, 'base64').toString('ascii');
return new AuthCredential(apiKey.split(';'));
}
/**
* Returns an AuthCredential generated using the given guest key.
*
* @param guestKey - The guest key to use for authenticating as guest.
* @returns The generated AuthCredential.
*/
private getGuestCredential(guestKey: string): AuthCredential {
return new AuthCredential(undefined, guestKey);
}
/**
* Checks the authorization status based on the requested resource.
*
* @param resourceType - The type of resource to fetch.
* @throws An error if not authorized to access the requested resource.
*/
private checkAuthorization(resourceType: EResourceType): void {
// Logging
this.logger.log(ELogActions.AUTHORIZATION, { authenticated: this.isAuthenticated });
// Checking authorization status
if (
resourceType != EResourceType.TWEET_DETAILS &&
resourceType != EResourceType.USER_DETAILS &&
resourceType != EResourceType.USER_TWEETS &&
this.isAuthenticated == false
) {
throw new Error(EApiErrors.RESOURCE_NOT_ALLOWED);
}
}
/**
* Gets the HttpsAgent based on whether a proxy is used or not.
*
* @param proxyUrl - Optional URL with proxy configuration to use for requests to Twitter API.
* @returns The HttpsAgent to use.
*/
private getHttpsAgent(proxyUrl?: URL): Agent {
if (proxyUrl) {
return new HttpsProxyAgent(proxyUrl);
}
return new https.Agent();
}
/**
* Makes an HTTP request according to the given parameters.
*
* @typeParam ResType - The type of the returned response data.
* @param config - The request configuration.
* @returns The response received.
*/
private async request<ResType>(config: AxiosRequestConfig): Promise<AxiosResponse<ResType>> {
// Checking authorization for the requested resource
this.checkAuthorization(config.url as EResourceType);
// If not authenticated, use guest authentication
this.cred = this.cred ?? (await new Auth({ proxyUrl: this.authProxyUrl }).getGuestCredential());
// Setting additional request parameters
config.headers = { ...config.headers, ...this.cred.toHeader() };
config.httpAgent = this.httpsAgent;
config.httpsAgent = this.httpsAgent;
config.timeout = this.timeout;
/**
* If Axios request results in an error, catch it and rethrow a more specific error.
*/
return await axios<ResType>(config).catch((error: unknown) => {
this.errorHandler.handle(error);
throw error;
});
}
/**
* Extracts the required data based on the type of resource passed as argument.
*
* @param data - The data from which extraction is to be done.
* @param type - The type of data to extract.
* @returns The extracted data.
*/
private extractData(
data: NonNullable<unknown>,
type: EResourceType,
): {
/** The required extracted data. */
required: (IRawTweet | IRawUser)[];
/** The cursor string to the next batch of data. */
next: string;
} {
/**
* The required extracted data.
*/
let required: IRawTweet[] | IRawUser[] = [];
if (type == EResourceType.TWEET_DETAILS) {
required = findByFilter<IRawTweet>(data, '__typename', 'Tweet');
} else if (type == EResourceType.USER_DETAILS || type == EResourceType.USER_DETAILS_BY_ID) {
required = findByFilter<IRawUser>(data, '__typename', 'User');
} else if (
type == EResourceType.TWEET_SEARCH ||
type == EResourceType.USER_LIKES ||
type == EResourceType.LIST_TWEETS ||
type == EResourceType.USER_TWEETS ||
type == EResourceType.USER_TWEETS_AND_REPLIES
) {
required = findByFilter<ITimelineTweet>(data, '__typename', 'TimelineTweet').map(
(item) => item.tweet_results.result,
);
} else if (
type == EResourceType.TWEET_FAVORITERS ||
type == EResourceType.TWEET_RETWEETERS ||
type == EResourceType.USER_FOLLOWERS ||
type == EResourceType.USER_FOLLOWING
) {
required = findByFilter<ITimelineUser>(data, '__typename', 'TimelineUser').map(
(item) => item.user_results.result,
);
}
return {
required: required,
next: findByFilter<IRawCursor>(data, 'cursorType', 'Bottom')[0]?.value,
};
}
/**
* Deserializes the extracted data into a cursored list.
*
* @param extractedData - The list of extracted data.
* @param next - The cursor to the next batch of data.
* @returns The cursored data object.
*/
private deserializeData<OutType extends Tweet | User>(
extractedData: (IRawTweet | IRawUser)[] = [],
next: string = '',
): CursoredData<OutType> {
/** The list of deserialized data. */
const deserializedList: OutType[] = [];
// Deserializing the extracted raw data and storing it in the list
for (const item of extractedData) {
// If the item is a valid raw tweet
if (item && item.__typename == 'Tweet' && item.rest_id && item.legacy) {
// Logging
this.logger.log(ELogActions.DESERIALIZE, { type: item.__typename, id: item.rest_id });
// Adding deserialized Tweet to list
deserializedList.push(new Tweet(item as IRawTweet) as OutType);
}
// If the item is a valid raw user
else if (item && item.__typename == 'User' && item.rest_id && (item as IRawUser).id && item.legacy) {
// Logging
this.logger.log(ELogActions.DESERIALIZE, { type: item.__typename, id: item.rest_id });
// Adding deserialized User to list
deserializedList.push(new User(item as IRawUser) as OutType);
}
}
return new CursoredData<OutType>(deserializedList, next);
}
/**
* Fetches the requested resource from Twitter and returns it after processing.
*
* @param resourceType - The type of resource to fetch.
* @param args - Resource specific arguments.
* @typeParam OutType - The type of deserialized data returned.
* @returns The processed data requested from Twitter.
*/
protected async fetch<OutType extends Tweet | User>(
resourceType: EResourceType,
args: FetchArgs,
): Promise<CursoredData<OutType>> {
// Logging
this.logger.log(ELogActions.FETCH, { resourceType: resourceType, args: args });
// Preparing the HTTP request
const request: AxiosRequestConfig = new Request(resourceType, args).toAxiosRequestConfig();
// Getting the raw data
const res = await this.request<IResponse<unknown>>(request).then((res) => res.data);
// Extracting data
const extractedData = this.extractData(res, resourceType);
// Deserializing data
const deserializedData = this.deserializeData<OutType>(extractedData.required, extractedData.next);
return deserializedData;
}
/**
* Posts the requested resource to Twitter and returns the response.
*
* @param resourceType - The type of resource to post.
* @param args - Resource specific arguments.
* @returns Whether posting was successful or not.
*/
protected async post(resourceType: EResourceType, args: PostArgs): Promise<boolean> {
// Logging
this.logger.log(ELogActions.POST, { resourceType: resourceType, args: args });
// Preparing the HTTP request
const request: AxiosRequestConfig = new Request(resourceType, args).toAxiosRequestConfig();
// Posting the data
await this.request<unknown>(request);
return true;
}
/**
* Uploads the given media file to Twitter
*
* @param media - The path to the media file to upload.
* @returns The id of the uploaded media.
*/
protected async upload(media: string): Promise<string> {
// INITIALIZE
// Logging
this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.INITIALIZE });
const id: string = (
await this.request<IMediaUploadInitializeResponse>(
new Request(EResourceType.MEDIA_UPLOAD, {
upload: { step: EUploadSteps.INITIALIZE, size: statSync(media).size },
}).toAxiosRequestConfig(),
)
).data.media_id_string;
// APPEND
// Logging
this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.APPEND });
await this.request<unknown>(
new Request(EResourceType.MEDIA_UPLOAD, {
upload: { step: EUploadSteps.APPEND, id: id, media: media },
}).toAxiosRequestConfig(),
);
// FINALIZE
// Logging
this.logger.log(ELogActions.UPLOAD, { step: EUploadSteps.APPEND });
await this.request<unknown>(
new Request(EResourceType.MEDIA_UPLOAD, {
upload: { step: EUploadSteps.FINALIZE, id: id },
}).toAxiosRequestConfig(),
);
return id;
}
}