Skip to content

Commit 8ba3a43

Browse files
authored
feat(web-api): add request interceptor and HTTP adapter config to WebClient (#2076), resolves #2073
1 parent 2af216c commit 8ba3a43

3 files changed

Lines changed: 245 additions & 15 deletions

File tree

docs/content/packages/web-api.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,65 @@ const web = new WebClient(token, { agent: proxy });
662662

663663
---
664664

665+
### Modify outgoing requests with a request interceptor
666+
667+
The client allows you to customize a request
668+
[`interceptor`](https://axios-http.com/docs/interceptors) to modify outgoing requests.
669+
Using this option allows you to modify outgoing requests to conform to the requirements of a proxy, which is a common requirement in many corporate settings.
670+
671+
For example you may want to wrap the original request information within a POST request:
672+
673+
```javascript
674+
const { WebClient } = require('@slack/web-api');
675+
676+
const token = process.env.SLACK_TOKEN;
677+
678+
const webClient = new WebClient(token, {
679+
requestInterceptor: (config) => {
680+
config.headers['Content-Type'] = 'application/json';
681+
682+
config.data = {
683+
method: config.method,
684+
base_url: config.baseURL,
685+
path: config.url,
686+
body: config.data ?? {},
687+
query: config.params ?? {},
688+
headers: structuredClone(config.headers),
689+
test: 'static-body-value',
690+
};
691+
692+
return config;
693+
}
694+
});
695+
```
696+
697+
---
698+
699+
### Using a pre-configured http client to handle outgoing requests
700+
701+
The client allows you to specify an
702+
[`adapter`](https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586) to handle outgoing requests.
703+
Using this option allows you to use a pre-configured http client, which is a common requirement in many corporate settings.
704+
705+
For example you may want to use an HTTP client which is already configured with logging capabilities, desired timeouts, etc.
706+
707+
```javascript
708+
const { WebClient } = require('@slack/web-api');
709+
const { CustomHttpClient } = require('@company/http-client')
710+
711+
const token = process.env.SLACK_TOKEN;
712+
713+
const customClient = CustomHttpClient();
714+
715+
const webClient = new WebClient(token, {
716+
adapter: (config: RequestConfig) => {
717+
return customClient.request(config);
718+
}
719+
});
720+
```
721+
722+
---
723+
665724
### Rate limits
666725
667726
When your app calls API methods too frequently, Slack will politely ask (by returning an error) the app to slow down,

packages/web-api/src/WebClient.spec.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import fs from 'node:fs';
2+
import axios, { type InternalAxiosRequestConfig } from 'axios';
23
import { assert, expect } from 'chai';
34
import nock from 'nock';
45
import sinon from 'sinon';
5-
import { type WebAPICallResult, WebClient, WebClientEvent, buildThreadTsWarningMessage } from './WebClient';
6+
import {
7+
type RequestConfig,
8+
type WebAPICallResult,
9+
WebClient,
10+
WebClientEvent,
11+
buildThreadTsWarningMessage,
12+
} from './WebClient';
613
import { ErrorCode, type WebAPIRequestError } from './errors';
714
import {
815
buildGeneralFilesUploadWarning,
@@ -964,6 +971,119 @@ describe('WebClient', () => {
964971
});
965972
});
966973

974+
describe('requestInterceptor', () => {
975+
function configureMockServer(expectedBody: () => Record<string, unknown>) {
976+
nock('https://slack.com/api', {
977+
reqheaders: {
978+
test: 'static-header-value',
979+
'Content-Type': 'application/json',
980+
},
981+
})
982+
.post(/method/, (requestBody) => {
983+
expect(requestBody).to.deep.equal(expectedBody());
984+
return true;
985+
})
986+
.reply(200, (_uri, requestBody) => {
987+
expect(requestBody).to.deep.equal(expectedBody());
988+
return { ok: true, response_metadata: requestBody };
989+
});
990+
}
991+
992+
it('can intercept out going requests, synchronously modifying the request body and headers', async () => {
993+
let expectedBody: Record<string, unknown>;
994+
995+
const client = new WebClient(token, {
996+
requestInterceptor: (config: RequestConfig) => {
997+
expectedBody = Object.freeze({
998+
method: config.method,
999+
base_url: config.baseURL,
1000+
path: config.url,
1001+
body: config.data ?? {},
1002+
query: config.params ?? {},
1003+
headers: structuredClone(config.headers),
1004+
test: 'static-body-value',
1005+
});
1006+
config.data = expectedBody;
1007+
1008+
config.headers.test = 'static-header-value';
1009+
config.headers['Content-Type'] = 'application/json';
1010+
1011+
return config;
1012+
},
1013+
});
1014+
1015+
configureMockServer(() => expectedBody);
1016+
1017+
await client.apiCall('method');
1018+
});
1019+
1020+
it('can intercept out going requests, asynchronously modifying the request body and headers', async () => {
1021+
let expectedBody: Record<string, unknown>;
1022+
1023+
const client = new WebClient(token, {
1024+
requestInterceptor: async (config: RequestConfig) => {
1025+
expectedBody = Object.freeze({
1026+
method: config.method,
1027+
base_url: config.baseURL,
1028+
path: config.url,
1029+
body: config.data ?? {},
1030+
query: config.params ?? {},
1031+
headers: structuredClone(config.headers),
1032+
test: 'static-body-value',
1033+
});
1034+
1035+
config.data = expectedBody;
1036+
1037+
config.headers.test = 'static-header-value';
1038+
config.headers['Content-Type'] = 'application/json';
1039+
1040+
return config;
1041+
},
1042+
});
1043+
1044+
configureMockServer(() => expectedBody);
1045+
1046+
await client.apiCall('method');
1047+
});
1048+
});
1049+
1050+
describe('adapter', () => {
1051+
it('allows for custom handling of requests with preconfigured http client', async () => {
1052+
nock('https://slack.com/api', {
1053+
reqheaders: {
1054+
'User-Agent': 'custom-axios-client',
1055+
},
1056+
})
1057+
.post(/method/)
1058+
.reply(200, (_uri, requestBody) => {
1059+
return { ok: true, response_metadata: requestBody };
1060+
});
1061+
1062+
const customLoggingInterceptor = (config: InternalAxiosRequestConfig) => {
1063+
// client with custom logging behaviour
1064+
return config;
1065+
};
1066+
const customLoggingSpy = sinon.spy(customLoggingInterceptor);
1067+
1068+
const customAxiosClient = axios.create();
1069+
customAxiosClient.interceptors.request.use(customLoggingSpy);
1070+
1071+
const customClientRequestSpy = sinon.spy(customAxiosClient, 'request');
1072+
1073+
const client = new WebClient(token, {
1074+
adapter: (config: RequestConfig) => {
1075+
config.headers['User-Agent'] = 'custom-axios-client';
1076+
return customAxiosClient.request(config);
1077+
},
1078+
});
1079+
1080+
await client.apiCall('method');
1081+
1082+
expect(customLoggingSpy.calledOnce).to.be.true;
1083+
expect(customClientRequestSpy.calledOnce).to.be.true;
1084+
});
1085+
});
1086+
9671087
it('should throw an error if the response has no retry info', async () => {
9681088
// @ts-expect-error header values cannot be undefined
9691089
const scope = nock('https://slack.com').post(/api/).reply(429, {}, { 'retry-after': undefined });

packages/web-api/src/WebClient.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { Agent } from 'node:http';
22
import { basename } from 'node:path';
33
import { stringify as qsStringify } from 'node:querystring';
4-
import type { Readable } from 'node:stream';
54
import type { SecureContextOptions } from 'node:tls';
65
import { TextDecoder } from 'node:util';
76
import zlib from 'node:zlib';
87

9-
import axios, { type AxiosHeaderValue, type AxiosInstance, type AxiosResponse } from 'axios';
8+
import axios, {
9+
type InternalAxiosRequestConfig,
10+
type AxiosHeaderValue,
11+
type AxiosInstance,
12+
type AxiosResponse,
13+
type AxiosAdapter,
14+
} from 'axios';
1015
import FormData from 'form-data';
1116
import isElectron from 'is-electron';
1217
import isStream from 'is-stream';
@@ -90,6 +95,20 @@ export interface WebClientOptions {
9095
* @default true
9196
*/
9297
attachOriginalToWebAPIRequestError?: boolean;
98+
/**
99+
* Custom function to modify outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptor documentation} for more details.
100+
* @type {Function | undefined}
101+
* @default undefined
102+
*/
103+
requestInterceptor?: RequestInterceptor;
104+
/**
105+
* Custom functions for modifing and handling outgoing requests.
106+
* Useful if you would like to manage outgoing request with a custom http client.
107+
* See {@link https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586 Axios adapter documentation} for more information.
108+
* @type {Function | undefined}
109+
* @default undefined
110+
*/
111+
adapter?: AdapterConfig;
93112
}
94113

95114
export type TLSOptions = Pick<SecureContextOptions, 'pfx' | 'key' | 'passphrase' | 'cert' | 'ca'>;
@@ -130,6 +149,24 @@ export type PageAccumulator<R extends PageReducer> = R extends (
130149
? A
131150
: never;
132151

152+
/**
153+
* An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L367 Axios' `InternalAxiosRequestConfig`} object,
154+
* which is the main parameter type provided to Axios interceptors and adapters.
155+
*/
156+
export type RequestConfig = InternalAxiosRequestConfig;
157+
158+
/**
159+
* An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L489 Axios' `AxiosInterceptorManager<InternalAxiosRequestConfig>` onFufilled} method,
160+
* which controls the custom request interceptor logic
161+
*/
162+
export type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
163+
164+
/**
165+
* An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L112 Axios' `AxiosAdapter`} interface,
166+
* which is the contract required to specify an adapter
167+
*/
168+
export type AdapterConfig = AxiosAdapter;
169+
133170
/**
134171
* A client for Slack's Web API
135172
*
@@ -196,6 +233,9 @@ export class WebClient extends Methods {
196233

197234
/**
198235
* @param token - An API token to authenticate/authorize with Slack (usually start with `xoxp`, `xoxb`)
236+
* @param {Object} [webClientOptions] - Configuration options.
237+
* @param {Function} [webClientOptions.requestInterceptor] - An interceptor to mutate outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptors}
238+
* @param {Function} [webClientOptions.adapter] - An adapter to allow custom handling of requests. Useful if you would like to use a pre-configured http client. See {@link https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586 Axios adapter}
199239
*/
200240
public constructor(
201241
token?: string,
@@ -212,6 +252,8 @@ export class WebClient extends Methods {
212252
headers = {},
213253
teamId = undefined,
214254
attachOriginalToWebAPIRequestError = true,
255+
requestInterceptor = undefined,
256+
adapter = undefined,
215257
}: WebClientOptions = {},
216258
) {
217259
super();
@@ -240,12 +282,12 @@ export class WebClient extends Methods {
240282
if (this.token && !headers.Authorization) headers.Authorization = `Bearer ${this.token}`;
241283

242284
this.axios = axios.create({
285+
adapter: adapter ? (config: InternalAxiosRequestConfig) => adapter({ ...config, adapter: undefined }) : undefined,
243286
timeout,
244287
baseURL: slackApiUrl,
245288
headers: isElectron() ? headers : { 'User-Agent': getUserAgent(), ...headers },
246289
httpAgent: agent,
247290
httpsAgent: agent,
248-
transformRequest: [this.serializeApiCallOptions.bind(this)],
249291
validateStatus: () => true, // all HTTP status codes should result in a resolved promise (as opposed to only 2xx)
250292
maxRedirects: 0,
251293
// disabling axios' automatic proxy support:
@@ -254,9 +296,16 @@ export class WebClient extends Methods {
254296
// protocols), users of this package should use the `agent` option to configure a proxy.
255297
proxy: false,
256298
});
257-
// serializeApiCallOptions will always determine the appropriate content-type
299+
// serializeApiCallData will always determine the appropriate content-type
258300
this.axios.defaults.headers.post['Content-Type'] = undefined;
259301

302+
// request interceptors have reversed execution order
303+
// see: https://github.com/axios/axios/blob/v1.x/test/specs/interceptors.spec.js#L88
304+
if (requestInterceptor) {
305+
this.axios.interceptors.request.use(requestInterceptor, null);
306+
}
307+
this.axios.interceptors.request.use(this.serializeApiCallData.bind(this), null);
308+
260309
this.logger.debug('initialized');
261310
}
262311

@@ -667,18 +716,16 @@ export class WebClient extends Methods {
667716
* a string, used when posting with a content-type of url-encoded. Or, it can be a readable stream, used
668717
* when the options contain a binary (a stream or a buffer) and the upload should be done with content-type
669718
* multipart/form-data.
670-
* @param options - arguments for the Web API method
671-
* @param headers - a mutable object representing the HTTP headers for the outgoing request
719+
* @param config - The Axios request configuration object
672720
*/
673-
private serializeApiCallOptions(
674-
options: Record<string, unknown>,
675-
headers?: Record<string, string>,
676-
): string | Readable {
721+
private serializeApiCallData(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
722+
const { data, headers } = config;
723+
677724
// The following operation both flattens complex objects into a JSON-encoded strings and searches the values for
678725
// binary content
679726
let containsBinaryData = false;
680-
// biome-ignore lint/suspicious/noExplicitAny: call options can be anything
681-
const flattened = Object.entries(options).map<[string, any] | []>(([key, value]) => {
727+
// biome-ignore lint/suspicious/noExplicitAny: HTTP request data can be anything
728+
const flattened = Object.entries(data).map<[string, any] | []>(([key, value]) => {
682729
if (value === undefined || value === null) {
683730
return [];
684731
}
@@ -730,21 +777,25 @@ export class WebClient extends Methods {
730777
headers[header] = value;
731778
}
732779
}
733-
return form;
780+
config.data = form;
781+
config.headers = headers;
782+
return config;
734783
}
735784

736785
// Otherwise, a simple key-value object is returned
737786
if (headers) headers['Content-Type'] = 'application/x-www-form-urlencoded';
738787
// biome-ignore lint/suspicious/noExplicitAny: form values can be anything
739788
const initialValue: { [key: string]: any } = {};
740-
return qsStringify(
789+
config.data = qsStringify(
741790
flattened.reduce((accumulator, [key, value]) => {
742791
if (key !== undefined && value !== undefined) {
743792
accumulator[key] = value;
744793
}
745794
return accumulator;
746795
}, initialValue),
747796
);
797+
config.headers = headers;
798+
return config;
748799
}
749800

750801
/**

0 commit comments

Comments
 (0)