twitter-api-v2
Version:
Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.
388 lines (387 loc) • 15 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RequestHandlerHelper = exports.ClientRequestMaker = void 0;
const types_1 = require("../types");
const TweetStream_1 = __importDefault(require("../stream/TweetStream"));
const url_1 = require("url");
const https_1 = require("https");
const helpers_1 = require("../helpers");
const oauth1_helper_1 = __importDefault(require("./oauth1.helper"));
const settings_1 = require("../settings");
const form_data_helper_1 = require("./form-data.helper");
class ClientRequestMaker {
/**
* Send a new request and returns a wrapped `Promise<TwitterResponse<T>`.
*
* The request URL should not contains a query string, prefers using `parameters` for GET request.
* If you need to pass a body AND query string parameter, duplicate parameters in the body.
*/
send(options) {
const args = this.getHttpRequestArgs(options);
return this.httpSend(args.url, {
method: args.method,
headers: args.headers,
}, args.body);
}
/**
* Send a new request, then creates a stream from its as a `Promise<TwitterStream>`.
*
* The request URL should not contains a query string, prefers using `parameters` for GET request.
* If you need to pass a body AND query string parameter, duplicate parameters in the body.
*/
sendStream(options) {
const args = this.getHttpRequestArgs(options);
return this.httpStream(args.url, {
method: args.method,
headers: args.headers,
}, args.body);
}
/* Token helpers */
buildOAuth() {
if (!this._consumerSecret || !this._consumerToken)
throw new Error('Invalid consumer tokens');
return new oauth1_helper_1.default({
consumerKeys: { key: this._consumerToken, secret: this._consumerSecret },
});
}
getOAuthAccessTokens() {
if (!this._accessSecret || !this._accessToken)
return;
return {
key: this._accessToken,
secret: this._accessSecret,
};
}
/* Request helpers */
writeAuthHeaders({ headers, bodyInSignature, url, method, query, body }) {
headers = { ...headers };
if (this._bearerToken) {
headers.Authorization = 'Bearer ' + this._bearerToken;
}
else if (this._basicToken) {
// Basic auth, to request a bearer token
headers.Authorization = 'Basic ' + this._basicToken;
}
else if (this._consumerSecret && this._oauth) {
// Merge query and body
const data = bodyInSignature ? RequestParamHelpers.mergeQueryAndBodyForOAuth(query, body) : query;
const auth = this._oauth.authorize({
url,
method,
data,
}, this.getOAuthAccessTokens());
headers = { ...headers, ...this._oauth.toHeader(auth) };
}
return headers;
}
getHttpRequestArgs({ url, method, query: rawQuery = {}, body: rawBody = {}, headers, forceBodyMode }) {
let body = undefined;
method = method.toUpperCase();
headers = headers !== null && headers !== void 0 ? headers : {};
// Add user agent header (Twitter recommands it)
if (!headers['x-user-agent']) {
headers['x-user-agent'] = 'Node.twitter-api-v2';
}
const query = RequestParamHelpers.formatQueryToString(rawQuery);
url = RequestParamHelpers.mergeUrlQueryIntoObject(url, query);
// Delete undefined parameters
if (!(rawBody instanceof Buffer)) {
helpers_1.trimUndefinedProperties(rawBody);
}
// OAuth signature should not include parameters when using multipart.
const bodyType = forceBodyMode !== null && forceBodyMode !== void 0 ? forceBodyMode : RequestParamHelpers.autoDetectBodyType(url);
// OAuth needs body signature only if body is URL encoded.
const bodyInSignature = ClientRequestMaker.BODY_METHODS.has(method) && bodyType === 'url';
headers = this.writeAuthHeaders({ headers, bodyInSignature, url, method, query, body: rawBody });
if (ClientRequestMaker.BODY_METHODS.has(method)) {
body = RequestParamHelpers.constructBodyParams(rawBody, headers, bodyType) || undefined;
}
url += RequestParamHelpers.constructGetParams(query);
return {
url,
method,
headers,
body,
};
}
httpSend(url, options, body) {
if (body) {
RequestParamHelpers.setBodyLengthHeader(options, body);
}
return new RequestHandlerHelper({ url, options, body })
.makeRequest();
}
httpStream(url, options, body) {
if (body) {
RequestParamHelpers.setBodyLengthHeader(options, body);
}
return new RequestHandlerHelper({ url, options, body })
.makeRequestAsStream();
}
}
exports.ClientRequestMaker = ClientRequestMaker;
ClientRequestMaker.BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']);
/* Helpers functions that are specific to this class but do not depends on instance */
class RequestParamHelpers {
static formatQueryToString(query) {
const formattedQuery = {};
for (const prop in query) {
if (typeof query[prop] === 'string') {
formattedQuery[prop] = query[prop];
}
else if (typeof query[prop] !== 'undefined') {
formattedQuery[prop] = String(query[prop]);
}
}
return formattedQuery;
}
static autoDetectBodyType(url) {
if (url.includes('.twitter.com/2')) {
// Twitter API v2 always has JSON-encoded requests, right?
return 'json';
}
if (url.startsWith('https://upload.twitter.com/1.1/media')) {
return 'form-data';
}
const endpoint = url.split('.twitter.com/1.1/', 2)[1];
if (this.JSON_1_1_ENDPOINTS.has(endpoint)) {
return 'json';
}
return 'url';
}
static constructGetParams(query) {
if (Object.keys(query).length)
return '?' + new url_1.URLSearchParams(query).toString();
return '';
}
static constructBodyParams(body, headers, mode) {
if (body instanceof Buffer) {
return body;
}
if (mode === 'json') {
headers['content-type'] = 'application/json;charset=UTF-8';
return JSON.stringify(body);
}
else if (mode === 'url') {
headers['content-type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
if (Object.keys(body).length)
return new url_1.URLSearchParams(body).toString();
return '';
}
else if (mode === 'raw') {
throw new Error('You can only use raw body mode with Buffers. To give a string, use Buffer.from(str).');
}
else {
const form = new form_data_helper_1.FormDataHelper();
for (const parameter in body) {
form.append(parameter, body[parameter]);
}
const formHeaders = form.getHeaders();
headers['content-type'] = formHeaders['content-type'];
return form.getBuffer();
}
}
static setBodyLengthHeader(options, body) {
var _a;
options.headers = (_a = options.headers) !== null && _a !== void 0 ? _a : {};
if (typeof body === 'string') {
options.headers['content-length'] = Buffer.byteLength(body);
}
else {
options.headers['content-length'] = body.length;
}
}
static isOAuthSerializable(item) {
return !(item instanceof Buffer);
}
static mergeQueryAndBodyForOAuth(query, body) {
const parameters = {};
for (const prop in query) {
parameters[prop] = query[prop];
}
if (this.isOAuthSerializable(body)) {
for (const prop in body) {
const bodyProp = body[prop];
if (this.isOAuthSerializable(bodyProp)) {
parameters[prop] = bodyProp;
}
}
}
return parameters;
}
static mergeUrlQueryIntoObject(url, query) {
const urlObject = new URL(url);
for (const [param, value] of urlObject.searchParams) {
query[param] = value;
}
// Remove the query string
return urlObject.href.slice(0, urlObject.href.length - urlObject.search.length);
}
}
RequestParamHelpers.JSON_1_1_ENDPOINTS = new Set([
'direct_messages/events/new',
'direct_messages/welcome_messages/new',
'direct_messages/welcome_messages/rules/new',
'media/metadata/create',
'collections/entries/curate',
]);
class RequestHandlerHelper {
constructor(requestData) {
this.requestData = requestData;
this.responseData = '';
}
get href() {
return this.requestData.url;
}
isFormEncodedEndpoint() {
return this.href.startsWith(RequestHandlerHelper.FORM_ENCODED_ENDPOINTS);
}
getRateLimitFromResponse(res) {
let rateLimit = undefined;
if (res.headers['x-rate-limit-limit']) {
rateLimit = {
limit: Number(res.headers['x-rate-limit-limit']),
remaining: Number(res.headers['x-rate-limit-remaining']),
reset: Number(res.headers['x-rate-limit-reset']),
};
}
return rateLimit;
}
createRequestError(error) {
if (settings_1.TwitterApiV2Settings.debug) {
console.log('Request network error:', error);
}
return new types_1.ApiRequestError('Request failed.', {
request: this.req,
error,
});
}
formatV1Errors(errors) {
return errors
.map(({ code, message }) => `${message} (Twitter code ${code})`)
.join(', ');
}
formatV2Error(error) {
return `${error.title}: ${error.detail} (see ${error.type})`;
}
createResponseError({ res, data, rateLimit, code }) {
var _a;
if (settings_1.TwitterApiV2Settings.debug) {
console.log('Request failed with code', code, ', data:', data, 'response headers:', res.headers);
}
// Errors formatting.
let errorString = `Request failed with code ${code}`;
if ((_a = data === null || data === void 0 ? void 0 : data.errors) === null || _a === void 0 ? void 0 : _a.length) {
const errors = data.errors;
if ('code' in errors[0]) {
errorString += ' - ' + this.formatV1Errors(errors);
}
else {
errorString += ' - ' + this.formatV2Error(data);
}
}
return new types_1.ApiResponseError(errorString, {
code,
data,
headers: res.headers,
request: this.req,
response: res,
rateLimit,
});
}
registerRequestErrorHandler(reject) {
return (requestError) => {
reject(this.createRequestError(requestError));
};
}
registerResponseHandler(resolve, reject) {
return (res) => {
const rateLimit = this.getRateLimitFromResponse(res);
// Register the response data
res.on('data', chunk => this.responseData += chunk);
res.on('end', () => {
var _a;
let data = this.responseData;
// Auto parse if server responds with JSON body
if (data.length && ((_a = res.headers['content-type']) === null || _a === void 0 ? void 0 : _a.includes('application/json'))) {
data = JSON.parse(data);
}
// f-e oauth token endpoints
else if (this.isFormEncodedEndpoint()) {
const response_form_entries = {};
for (const [item, value] of new url_1.URLSearchParams(data)) {
response_form_entries[item] = value;
}
data = response_form_entries;
}
// Handle bad error codes
const code = res.statusCode;
if (code >= 400) {
reject(this.createResponseError({ data, res, rateLimit, code }));
}
resolve({
data,
headers: res.headers,
rateLimit
});
});
};
}
registerStreamResponseHandler(resolve, reject) {
return (res) => {
const code = res.statusCode;
if (code < 400) {
// HTTP code ok, consume stream
resolve({ req: this.req, res, requestData: this.requestData });
}
else {
// Handle response normally, can only rejects
this.registerResponseHandler(() => undefined, reject)(res);
}
};
}
debugRequest() {
console.log('Request to', this.requestData.url, 'will be made.', 'Options:', this.requestData.options, 'body:', this.requestData.body);
}
buildRequest() {
if (settings_1.TwitterApiV2Settings.debug) {
this.debugRequest();
}
this.req = https_1.request(this.requestData.url, this.requestData.options);
}
makeRequest() {
this.buildRequest();
return new Promise((resolve, reject) => {
const req = this.req;
// Handle request errors
req.on('error', this.registerRequestErrorHandler(reject));
req.on('response', this.registerResponseHandler(resolve, reject));
if (this.requestData.body) {
req.write(this.requestData.body);
}
req.end();
});
}
async makeRequestAsStream() {
const { req, res, requestData } = await this.makeRequestAndResolveWhenReady();
return new TweetStream_1.default(req, res, requestData);
}
makeRequestAndResolveWhenReady() {
this.buildRequest();
return new Promise((resolve, reject) => {
const req = this.req;
// Handle request errors
req.on('error', this.registerRequestErrorHandler(reject));
req.on('response', this.registerStreamResponseHandler(resolve, reject));
if (this.requestData.body) {
req.write(this.requestData.body);
}
req.end();
});
}
}
exports.RequestHandlerHelper = RequestHandlerHelper;
RequestHandlerHelper.FORM_ENCODED_ENDPOINTS = 'https://api.twitter.com/oauth/';