Skip to content

Commit

Permalink
Merge pull request #188 from PLhery/feat/plugins
Browse files Browse the repository at this point in the history
feat: Plugins
  • Loading branch information
alkihis authored Feb 16, 2022
2 parents abab2bd + 11fcee5 commit 6cd5558
Show file tree
Hide file tree
Showing 25 changed files with 699 additions and 306 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Learn how to use the full potential of `twitter-api-v2`.
- [Create a client and make your first request](./doc/basics.md)
- [Handle Twitter authentication flows](./doc/auth.md)
- [Explore some examples](./doc/examples.md)
- [Use and create plugins](./doc/plugins.md)
- Use endpoints wrappers — ensure typings of request & response
- [Available endpoint wrappers for v1.1 API](./doc/v1.md)
- [Available endpoint wrappers for v2 API](./doc/v2.md)
Expand Down
48 changes: 1 addition & 47 deletions doc/http-wrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,50 +44,4 @@ console.log(res.rateLimit, res.data)
// [headers]
// Customize sent HTTP headers
client.v1.post('statuses/update.json', { status: 'Hello' }, { headers: { 'X-Custom-Header': 'My Header Value' } })
```

## Advanced: make a custom signed request

`twitter-api-v2` gives you a client that handles all the request signin boilerplate for you.

Sometimes, you need to dive deep and make the request on your own.
2 raw helpers allow you to make the request you want:
- `.send`: Make a request, awaits its complete response, parse it and returns it
- `.sendStream`: Make a requests, returns a stream when server responds OK

**Warning**: When you use those methods, you need to prefix your requests (no auto-prefixing)!
Make sure you use a URL that begins with `https://...` with raw request managers.

### .send

**Template types**: `T = any`

**Args**: `IGetHttpRequestArgs`

**Returns**: (async) `TwitterResponse<T>`

```ts
const response = await client.send({
method: 'GET',
url: 'https://api.twitter.com/2/tweets/search/all',
query: { max_results: 200 },
headers: { 'X-Custom-Header': 'True' },
});

response.data; // Twitter response body: { data: Tweet[], meta: {...} }
response.rateLimit.limit; // Ex: 900
```

### .sendStream

**Args**: `IGetHttpRequestArgs`

**Returns**: (async) `TweetStream`

```ts
const stream = await client.sendStream({
method: 'GET',
url: 'https://api.twitter.com/2/tweets/sample/stream',
});
// For response handling, see streaming documentation
```
```
65 changes: 65 additions & 0 deletions doc/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Plugins for `twitter-api-v2`

Since version `1.11.0`, library supports plugins.
Plugins are objects exposing specific functions, called by the library at specific times.

## Using plugins

Import your plugin, instanciate them (if needed), and give them in the `plugins` array of client settings.

```ts
import { TwitterApi } from 'twitter-api-v2'
import { TwitterApiCachePluginRedis } from '@twitter-api-v2/plugin-cache-redis'

const redisPlugin = new TwitterApiCachePluginRedis(redisInstance)

const client = new TwitterApi(yourKeys, { plugins: [redisPlugin] })
```

## Writing plugins

You can write object/classes that implements the following interface:
```ts
interface ITwitterApiClientPlugin {
// Classic requests
/* Executed when request is about to be prepared. OAuth headers, body, query normalization hasn't been done yet. */
onBeforeRequestConfig?: TTwitterApiBeforeRequestConfigHook
/* Executed when request is about to be made. Headers/body/query has been prepared, and HTTP options has been initialized. */
onBeforeRequest?: TTwitterApiBeforeRequestHook
/* Executed when a request succeeds (failed requests don't trigger this hook). */
onAfterRequest?: TTwitterApiAfterRequestHook
// Stream requests
/* Executed when a stream request is about to be prepared. This method **can't** return a `Promise`. */
onBeforeStreamRequestConfig?: TTwitterApiBeforeStreamRequestConfigHook
// Request token
/* Executed after a `.generateAuthLink`, mainly to allow automatic collect of `oauth_token`/`oauth_token_secret` couples. */
onOAuth1RequestToken?: TTwitterApiAfterOAuth1RequestTokenHook
/* Executed after a `.generateOAuth2AuthLink`, mainly to allow automatic collect of `state`/`codeVerifier` couples. */
onOAuth2RequestToken?: TTwitterApiAfterOAuth2RequestTokenHook
}
```

Every method is optional, because you can implement whatever you want to listen to.

Method types:
```ts
type TTwitterApiBeforeRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => PromiseOrType<TwitterResponse<any> | void>
type TTwitterApiBeforeRequestHook = (args: ITwitterApiBeforeRequestHookArgs) => void | Promise<void>
type TTwitterApiAfterRequestHook = (args: ITwitterApiAfterRequestHookArgs) => void | Promise<void>
type TTwitterApiBeforeStreamRequestConfigHook = (args: ITwitterApiBeforeRequestConfigHookArgs) => void
type TTwitterApiAfterOAuth1RequestTokenHook = (args: ITwitterApiAfterOAuth1RequestTokenHookArgs) => void | Promise<void>
type TTwitterApiAfterOAuth2RequestTokenHook = (args: ITwitterApiAfterOAuth2RequestTokenHookArgs) => void | Promise<void>
```
A simple plugin implementation that logs GET requests can be:
```ts
class TwitterApiLoggerPlugin implements ITwitterApiClientPlugin {
onBeforeRequestConfig(args: ITwitterApiBeforeRequestConfigHookArgs) {
const method = args.params.method.toUpperCase()
console.log(`${method} ${args.url.toString()} ${JSON.stringify(args.params.query)}`)
}
}

const client = new TwitterApi(yourKeys, { plugins: [new TwitterApiLoggerPlugin()] })
```
37 changes: 14 additions & 23 deletions doc/rate-limiting.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,27 @@
# Rate limiting

## Get last rate limit info
## Extract rate limit information with plugins

### Using endpoint wrappers

You can obtain lastly collected information of rate limit for each already used endpoint.

First, you need to know **which endpoint URL is concerned by the used endpoint wrapper**, for example,
for `.v1.tweets`, it is `statuses/lookup.json`. The endpoint is always specified in the lib documentation.

Use the endpoint URL to know:
- The last received status of rate limiting with `.getLastRateLimitStatus`
- If the stored rate limit information has expired with `.isRateLimitStatusObsolete`
- If you hit the rate limit the last time you called this endpoint, with `.hasHitRateLimit`
Plugin `@twitter-api-v2/plugin-rate-limit` can help you to store/get rate limit information.
It stores automatically rate limits sent by Twitter at each request and gives you an API to get them when you need to.

```ts
// Usage of statuses/lookup.json
const tweets = await client.v1.tweets(['20', '30']);
import { TwitterApi } from 'twitter-api-v2'
import { TwitterApiRateLimitPlugin } from '@twitter-api-v2/plugin-rate-limit'

// Don't forget to add .v1, otherwise you need to prefix
// your endpoint URL with https://api.twitter.com/... :)
console.log(client.v1.getLastRateLimitStatus('statuses/lookup.json'));
// => { limit: 900, remaining: 899, reset: 1631015719 }
const rateLimitPlugin = new TwitterApiRateLimitPlugin()
const client = new TwitterApi(yourKeys, { plugins: [rateLimitPlugin] })

console.log(client.v1.isRateLimitStatusObsolete('statuses/lookup.json'));
// => false if 'reset' property mentions a timestamp in the future
// ...make requests...
await client.v2.me()
// ...

console.log(client.v1.hasHitRateLimit('statuses/lookup.json'));
// => false if 'remaining' property is > 0
const currentRateLimitForMe = await rateLimitPlugin.v2.getRateLimit('users/me')
console.log(currentRateLimitForMe.limit) // 75
console.log(currentRateLimitForMe.remaining) // 74
```

### Special case of HTTP methods helpers
## With HTTP methods helpers

If you use a HTTP method helper (`.get`, `.post`, ...), you can get a **full response** object that directly contains the rate limit information,
even if the request didn't fail!
Expand Down
11 changes: 7 additions & 4 deletions doc/v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -831,10 +831,13 @@ you **must** specify the file type using `options.type`.
**Arguments**:
- `file: string | number | Buffer | fs.promises.FileHandle`: File path (`string`) or file description (`number`) or raw file (`Buffer`) or file handle (`fs.promises.FileHandle`)
- `options?: UploadMediaV1Params`
- `options.type` File type (Enum `'jpg' | 'longmp4' | 'mp4' | 'png' | 'gif' | 'srt' | 'webp'`).
- `options.mimeType` MIME type as a string. To help you across allowed MIME types, enum `EUploadMimeType` is here for you.
This option is **required if file is not specified as `string`**.
If you already know the MIME type, you can specifiy the MIME type as a string, instead of using one of the previously seen enum values.
- `options.target` Target type `tweet` or `dm`. Defaults to `tweet`. **You must specify it if you send a media to use in DMs.**
- `options.longVideo` Specify `true` here if you're sending a video and it can exceed 120 seconds. Otherwise, this option has no effet.
- `options.shared` Specify `true` here if you want to use this media in Welcome Direct Messages.
- `options.additionalOwners` List of user IDs (except you) allowed to use the new media ID.
- `options.maxConcurrentUploads` Number of concurrent chunk uploads allowed to be sent. Defaults to `3`.

**Returns**: `string`: Media ID to give to tweets/DMs

Expand All @@ -848,10 +851,10 @@ const newTweet = await client.v1.tweet('Hello!', { media_ids: mediaId });
import { fileTypeFromFile } from 'file-type'; // You can use file-type to guess the file content

const path = '149e4f3.tmp';
const mediaId = await client.v1.uploadMedia(path, { type: (await fileTypeFromFile(path)).mime });
const mediaId = await client.v1.uploadMedia(path, { mimeType: (await fileTypeFromFile(path)).mime });

// Through a Buffer
const mediaId = await client.v1.uploadMedia(Buffer.from([...]), { type: 'png' });
const mediaId = await client.v1.uploadMedia(Buffer.from([...]), { mimeType: EUploadMimeType.Png });
```

### <a name='Mediainfo'></a>Media info
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"test-list": "npm run mocha test/list.*.test.ts",
"test-space": "npm run mocha test/space.v2.test.ts",
"test-account": "npm run mocha test/account.*.test.ts",
"test-plugin": "npm run mocha test/plugin.test.ts",
"prepublish": "npm run build"
},
"repository": "github:plhery/node-twitter-api-v2",
Expand Down
75 changes: 55 additions & 20 deletions src/client-mixins/request-handler.helper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Socket } from 'net';
import { request } from 'https';
import type { IncomingMessage, ClientRequest } from 'http';
import { TwitterApiV2Settings } from '../settings';
Expand All @@ -24,10 +25,13 @@ interface IBuildErrorParams {
export class RequestHandlerHelper<T> {
protected req!: ClientRequest;
protected res!: IncomingMessage;
protected requestErrorHandled = false;
protected responseData = '';

constructor(protected requestData: TRequestFullData | TRequestFullStreamData) {}

/* Request helpers */

get hrefPathname() {
const url = this.requestData.url;
return url.hostname + url.pathname;
Expand All @@ -41,23 +45,7 @@ export class RequestHandlerHelper<T> {
return this.requestData.url.href.startsWith('https://api.twitter.com/oauth/');
}

protected getRateLimitFromResponse(res: IncomingMessage) {
let rateLimit: TwitterRateLimit | undefined = 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']),
};

if (this.requestData.rateLimitSaver) {
this.requestData.rateLimitSaver(rateLimit);
}
}

return rateLimit;
}
/* Error helpers */

protected createRequestError(error: Error): ApiRequestError {
if (TwitterApiV2Settings.debug) {
Expand Down Expand Up @@ -127,6 +115,8 @@ export class RequestHandlerHelper<T> {
});
}

/* Response helpers */

protected getResponseDataStream(res: IncomingMessage) {
if (this.isCompressionDisabled()) {
return res;
Expand Down Expand Up @@ -186,17 +176,59 @@ export class RequestHandlerHelper<T> {
return data;
}

protected getRateLimitFromResponse(res: IncomingMessage) {
let rateLimit: TwitterRateLimit | undefined = 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']),
};

if (this.requestData.rateLimitSaver) {
this.requestData.rateLimitSaver(rateLimit);
}
}

return rateLimit;
}

/* Request event handlers */

protected onSocketEventHandler(reject: TRequestRejecter, socket: Socket) {
socket.on('close', this.onSocketCloseHandler.bind(this, reject));
}

protected onSocketCloseHandler(reject: TRequestRejecter) {
this.req.removeAllListeners('timeout');
const res = this.res;

if (res) {
// Response ok, res.close/res.end can handle request ending
return;
}
if (!this.requestErrorHandled) {
return reject(this.createRequestError(new Error('Socket closed without any information.')));
}

// else: other situation
}

protected requestErrorHandler(reject: TRequestRejecter, requestError: Error) {
this.requestData.requestEventDebugHandler?.('request-error', { requestError })

this.requestErrorHandled = true;
reject(this.createRequestError(requestError));
this.req.removeAllListeners('timeout');
}

protected timeoutErrorHandler() {
this.requestErrorHandled = true;
this.req.destroy(new Error('Request timeout.'));
}

/* Response event handlers */

protected classicResponseHandler(resolve: TResponseResolver<T>, reject: TResponseRejecter, res: IncomingMessage) {
this.res = res;

Expand All @@ -219,7 +251,6 @@ export class RequestHandlerHelper<T> {
}

protected onResponseEndHandler(resolve: TResponseResolver<T>, reject: TResponseRejecter) {
this.req.removeAllListeners('timeout');
const rateLimit = this.getRateLimitFromResponse(this.res);
let data: any;

Expand Down Expand Up @@ -250,7 +281,6 @@ export class RequestHandlerHelper<T> {
}

protected onResponseCloseHandler(resolve: TResponseResolver<T>, reject: TResponseRejecter) {
this.req.removeAllListeners('timeout');
const res = this.res;

if (res.aborted) {
Expand Down Expand Up @@ -293,6 +323,8 @@ export class RequestHandlerHelper<T> {
}
}

/* Wrappers for request lifecycle */

protected debugRequest() {
const url = this.requestData.url;

Expand Down Expand Up @@ -335,6 +367,7 @@ export class RequestHandlerHelper<T> {
}

protected registerRequestEventDebugHandlers(req: ClientRequest) {
req.on('close', () => this.requestData.requestEventDebugHandler!('close'));
req.on('abort', () => this.requestData.requestEventDebugHandler!('abort'));

req.on('socket', socket => {
Expand All @@ -358,6 +391,8 @@ export class RequestHandlerHelper<T> {
// Handle request errors
req.on('error', this.requestErrorHandler.bind(this, reject));

req.on('socket', this.onSocketEventHandler.bind(this, reject));

req.on('response', this.classicResponseHandler.bind(this, resolve, reject));

if (this.requestData.options.timeout) {
Expand Down
Loading

0 comments on commit 6cd5558

Please sign in to comment.