diff --git a/docs/content/packages/web-api.md b/docs/content/packages/web-api.md index 97961e1af..4983b8f9d 100644 --- a/docs/content/packages/web-api.md +++ b/docs/content/packages/web-api.md @@ -662,6 +662,65 @@ const web = new WebClient(token, { agent: proxy }); --- +### Modify outgoing requests with a request interceptor + +The client allows you to customize a request +[`interceptor`](https://axios-http.com/docs/interceptors) to modify outgoing requests. +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. + +For example you may want to wrap the original request information within a POST request: + +```javascript +const { WebClient } = require('@slack/web-api'); + +const token = process.env.SLACK_TOKEN; + +const webClient = new WebClient(token, { + requestInterceptor: (config) => { + config.headers['Content-Type'] = 'application/json'; + + config.data = { + method: config.method, + base_url: config.baseURL, + path: config.url, + body: config.data ?? {}, + query: config.params ?? {}, + headers: structuredClone(config.headers), + test: 'static-body-value', + }; + + return config; + } +}); +``` + +--- + +### Using a pre-configured http client to handle outgoing requests + +The client allows you to specify an +[`adapter`](https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586) to handle outgoing requests. +Using this option allows you to use a pre-configured http client, which is a common requirement in many corporate settings. + +For example you may want to use an HTTP client which is already configured with logging capabilities, desired timeouts, etc. + +```javascript +const { WebClient } = require('@slack/web-api'); +const { CustomHttpClient } = require('@company/http-client') + +const token = process.env.SLACK_TOKEN; + +const customClient = CustomHttpClient(); + +const webClient = new WebClient(token, { + adapter: (config: RequestConfig) => { + return customClient.request(config); + } +}); +``` + +--- + ### Rate limits When your app calls API methods too frequently, Slack will politely ask (by returning an error) the app to slow down, diff --git a/packages/web-api/src/WebClient.spec.ts b/packages/web-api/src/WebClient.spec.ts index 03a4165c1..a65c4e8d9 100644 --- a/packages/web-api/src/WebClient.spec.ts +++ b/packages/web-api/src/WebClient.spec.ts @@ -1,8 +1,15 @@ import fs from 'node:fs'; +import axios, { type InternalAxiosRequestConfig } from 'axios'; import { assert, expect } from 'chai'; import nock from 'nock'; import sinon from 'sinon'; -import { type WebAPICallResult, WebClient, WebClientEvent, buildThreadTsWarningMessage } from './WebClient'; +import { + type RequestConfig, + type WebAPICallResult, + WebClient, + WebClientEvent, + buildThreadTsWarningMessage, +} from './WebClient'; import { ErrorCode, type WebAPIRequestError } from './errors'; import { buildGeneralFilesUploadWarning, @@ -964,6 +971,119 @@ describe('WebClient', () => { }); }); + describe('requestInterceptor', () => { + function configureMockServer(expectedBody: () => Record) { + nock('https://slack.com/api', { + reqheaders: { + test: 'static-header-value', + 'Content-Type': 'application/json', + }, + }) + .post(/method/, (requestBody) => { + expect(requestBody).to.deep.equal(expectedBody()); + return true; + }) + .reply(200, (_uri, requestBody) => { + expect(requestBody).to.deep.equal(expectedBody()); + return { ok: true, response_metadata: requestBody }; + }); + } + + it('can intercept out going requests, synchronously modifying the request body and headers', async () => { + let expectedBody: Record; + + const client = new WebClient(token, { + requestInterceptor: (config: RequestConfig) => { + expectedBody = Object.freeze({ + method: config.method, + base_url: config.baseURL, + path: config.url, + body: config.data ?? {}, + query: config.params ?? {}, + headers: structuredClone(config.headers), + test: 'static-body-value', + }); + config.data = expectedBody; + + config.headers.test = 'static-header-value'; + config.headers['Content-Type'] = 'application/json'; + + return config; + }, + }); + + configureMockServer(() => expectedBody); + + await client.apiCall('method'); + }); + + it('can intercept out going requests, asynchronously modifying the request body and headers', async () => { + let expectedBody: Record; + + const client = new WebClient(token, { + requestInterceptor: async (config: RequestConfig) => { + expectedBody = Object.freeze({ + method: config.method, + base_url: config.baseURL, + path: config.url, + body: config.data ?? {}, + query: config.params ?? {}, + headers: structuredClone(config.headers), + test: 'static-body-value', + }); + + config.data = expectedBody; + + config.headers.test = 'static-header-value'; + config.headers['Content-Type'] = 'application/json'; + + return config; + }, + }); + + configureMockServer(() => expectedBody); + + await client.apiCall('method'); + }); + }); + + describe('adapter', () => { + it('allows for custom handling of requests with preconfigured http client', async () => { + nock('https://slack.com/api', { + reqheaders: { + 'User-Agent': 'custom-axios-client', + }, + }) + .post(/method/) + .reply(200, (_uri, requestBody) => { + return { ok: true, response_metadata: requestBody }; + }); + + const customLoggingInterceptor = (config: InternalAxiosRequestConfig) => { + // client with custom logging behaviour + return config; + }; + const customLoggingSpy = sinon.spy(customLoggingInterceptor); + + const customAxiosClient = axios.create(); + customAxiosClient.interceptors.request.use(customLoggingSpy); + + const customClientRequestSpy = sinon.spy(customAxiosClient, 'request'); + + const client = new WebClient(token, { + adapter: (config: RequestConfig) => { + config.headers['User-Agent'] = 'custom-axios-client'; + return customAxiosClient.request(config); + }, + }); + + await client.apiCall('method'); + + expect(customLoggingSpy.calledOnce).to.be.true; + expect(customClientRequestSpy.calledOnce).to.be.true; + }); + }); + it('should throw an error if the response has no retry info', async () => { // @ts-expect-error header values cannot be undefined const scope = nock('https://slack.com').post(/api/).reply(429, {}, { 'retry-after': undefined }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 7728f59c7..8389a6361 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -1,12 +1,17 @@ import type { Agent } from 'node:http'; import { basename } from 'node:path'; import { stringify as qsStringify } from 'node:querystring'; -import type { Readable } from 'node:stream'; import type { SecureContextOptions } from 'node:tls'; import { TextDecoder } from 'node:util'; import zlib from 'node:zlib'; -import axios, { type AxiosHeaderValue, type AxiosInstance, type AxiosResponse } from 'axios'; +import axios, { + type InternalAxiosRequestConfig, + type AxiosHeaderValue, + type AxiosInstance, + type AxiosResponse, + type AxiosAdapter, +} from 'axios'; import FormData from 'form-data'; import isElectron from 'is-electron'; import isStream from 'is-stream'; @@ -90,6 +95,20 @@ export interface WebClientOptions { * @default true */ attachOriginalToWebAPIRequestError?: boolean; + /** + * Custom function to modify outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptor documentation} for more details. + * @type {Function | undefined} + * @default undefined + */ + requestInterceptor?: RequestInterceptor; + /** + * Custom functions for modifing and handling outgoing requests. + * Useful if you would like to manage outgoing request with a custom http client. + * See {@link https://github.com/axios/axios/blob/v1.x/README.md?plain=1#L586 Axios adapter documentation} for more information. + * @type {Function | undefined} + * @default undefined + */ + adapter?: AdapterConfig; } export type TLSOptions = Pick; @@ -130,6 +149,24 @@ export type PageAccumulator = R extends ( ? A : never; +/** + * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L367 Axios' `InternalAxiosRequestConfig`} object, + * which is the main parameter type provided to Axios interceptors and adapters. + */ +export type RequestConfig = InternalAxiosRequestConfig; + +/** + * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L489 Axios' `AxiosInterceptorManager` onFufilled} method, + * which controls the custom request interceptor logic + */ +export type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise; + +/** + * An alias to {@link https://github.com/axios/axios/blob/v1.x/index.d.ts#L112 Axios' `AxiosAdapter`} interface, + * which is the contract required to specify an adapter + */ +export type AdapterConfig = AxiosAdapter; + /** * A client for Slack's Web API * @@ -196,6 +233,9 @@ export class WebClient extends Methods { /** * @param token - An API token to authenticate/authorize with Slack (usually start with `xoxp`, `xoxb`) + * @param {Object} [webClientOptions] - Configuration options. + * @param {Function} [webClientOptions.requestInterceptor] - An interceptor to mutate outgoing requests. See {@link https://axios-http.com/docs/interceptors Axios interceptors} + * @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} */ public constructor( token?: string, @@ -212,6 +252,8 @@ export class WebClient extends Methods { headers = {}, teamId = undefined, attachOriginalToWebAPIRequestError = true, + requestInterceptor = undefined, + adapter = undefined, }: WebClientOptions = {}, ) { super(); @@ -240,12 +282,12 @@ export class WebClient extends Methods { if (this.token && !headers.Authorization) headers.Authorization = `Bearer ${this.token}`; this.axios = axios.create({ + adapter: adapter ? (config: InternalAxiosRequestConfig) => adapter({ ...config, adapter: undefined }) : undefined, timeout, baseURL: slackApiUrl, headers: isElectron() ? headers : { 'User-Agent': getUserAgent(), ...headers }, httpAgent: agent, httpsAgent: agent, - transformRequest: [this.serializeApiCallOptions.bind(this)], validateStatus: () => true, // all HTTP status codes should result in a resolved promise (as opposed to only 2xx) maxRedirects: 0, // disabling axios' automatic proxy support: @@ -254,9 +296,16 @@ export class WebClient extends Methods { // protocols), users of this package should use the `agent` option to configure a proxy. proxy: false, }); - // serializeApiCallOptions will always determine the appropriate content-type + // serializeApiCallData will always determine the appropriate content-type this.axios.defaults.headers.post['Content-Type'] = undefined; + // request interceptors have reversed execution order + // see: https://github.com/axios/axios/blob/v1.x/test/specs/interceptors.spec.js#L88 + if (requestInterceptor) { + this.axios.interceptors.request.use(requestInterceptor, null); + } + this.axios.interceptors.request.use(this.serializeApiCallData.bind(this), null); + this.logger.debug('initialized'); } @@ -667,18 +716,16 @@ export class WebClient extends Methods { * a string, used when posting with a content-type of url-encoded. Or, it can be a readable stream, used * when the options contain a binary (a stream or a buffer) and the upload should be done with content-type * multipart/form-data. - * @param options - arguments for the Web API method - * @param headers - a mutable object representing the HTTP headers for the outgoing request + * @param config - The Axios request configuration object */ - private serializeApiCallOptions( - options: Record, - headers?: Record, - ): string | Readable { + private serializeApiCallData(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { + const { data, headers } = config; + // The following operation both flattens complex objects into a JSON-encoded strings and searches the values for // binary content let containsBinaryData = false; - // biome-ignore lint/suspicious/noExplicitAny: call options can be anything - const flattened = Object.entries(options).map<[string, any] | []>(([key, value]) => { + // biome-ignore lint/suspicious/noExplicitAny: HTTP request data can be anything + const flattened = Object.entries(data).map<[string, any] | []>(([key, value]) => { if (value === undefined || value === null) { return []; } @@ -730,14 +777,16 @@ export class WebClient extends Methods { headers[header] = value; } } - return form; + config.data = form; + config.headers = headers; + return config; } // Otherwise, a simple key-value object is returned if (headers) headers['Content-Type'] = 'application/x-www-form-urlencoded'; // biome-ignore lint/suspicious/noExplicitAny: form values can be anything const initialValue: { [key: string]: any } = {}; - return qsStringify( + config.data = qsStringify( flattened.reduce((accumulator, [key, value]) => { if (key !== undefined && value !== undefined) { accumulator[key] = value; @@ -745,6 +794,8 @@ export class WebClient extends Methods { return accumulator; }, initialValue), ); + config.headers = headers; + return config; } /**