From c21f3b3dc9386c0b51de36385c38890fa78854b0 Mon Sep 17 00:00:00 2001 From: Denis DelGrosso Date: Thu, 2 May 2024 18:28:24 +0000 Subject: [PATCH] feat: add request and response interceptors --- src/gaxios.ts | 67 ++++++++++++- src/index.ts | 1 + src/interceptor.ts | 104 ++++++++++++++++++++ test/test.getch.ts | 233 +++++++++++++++++++++++++++++++++++++++++++++ test/test.index.ts | 1 + 5 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/interceptor.ts diff --git a/src/gaxios.ts b/src/gaxios.ts index a0daeec5..e44a7b55 100644 --- a/src/gaxios.ts +++ b/src/gaxios.ts @@ -32,6 +32,7 @@ import { import {getRetryConfig} from './retry'; import {PassThrough, Stream, pipeline} from 'stream'; import {v4} from 'uuid'; +import {GaxiosInterceptorManager} from './interceptor'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -63,6 +64,11 @@ function getHeader(options: GaxiosOptions, header: string): string | undefined { return undefined; } +enum GaxiosInterceptorType { + Request = 1, + Response, +} + export class Gaxios { protected agentCache = new Map< string | URL, @@ -74,12 +80,24 @@ export class Gaxios { */ defaults: GaxiosOptions; + /** + * Interceptors + */ + interceptors: { + request: GaxiosInterceptorManager; + response: GaxiosInterceptorManager; + }; + /** * The Gaxios class is responsible for making HTTP requests. * @param defaults The default set of options to be used for this instance. */ constructor(defaults?: GaxiosOptions) { this.defaults = defaults || {}; + this.interceptors = { + request: new GaxiosInterceptorManager(), + response: new GaxiosInterceptorManager(), + }; } /** @@ -88,7 +106,11 @@ export class Gaxios { */ async request(opts: GaxiosOptions = {}): GaxiosPromise { opts = await this.#prepareRequest(opts); - return this._request(opts); + opts = await this.#applyInterceptors(opts); + return this.#applyInterceptors( + this._request(opts), + GaxiosInterceptorType.Response + ); } private async _defaultAdapter( @@ -230,6 +252,49 @@ export class Gaxios { return true; } + /** + * Applies the interceptors. The request interceptors are applied after the + * call to prepareRequest is completed. The response interceptors are applied after the call + * to translateResponse. + * + * @param {T} optionsOrResponse The current set of options or the translated response. + * + * @returns {Promise} Promise that resolves to the set of options or response after interceptors are applied. + */ + async #applyInterceptors< + T extends + | GaxiosOptions + | GaxiosResponse + | Promise, + >( + optionsOrResponse: T, + type: GaxiosInterceptorType = GaxiosInterceptorType.Request + ): Promise { + let promiseChain = Promise.resolve(optionsOrResponse) as Promise; + + if (type === GaxiosInterceptorType.Request) { + for (const interceptor of this.interceptors.request) { + if (interceptor) { + promiseChain = promiseChain.then( + interceptor.resolved as unknown as (opts: T) => Promise, + interceptor.rejected + ) as Promise; + } + } + } else { + for (const interceptor of this.interceptors.response) { + if (interceptor) { + promiseChain = promiseChain.then( + interceptor.resolved as unknown as (resp: T) => Promise, + interceptor.rejected + ) as Promise; + } + } + } + + return promiseChain; + } + /** * Validates the options, merges them with defaults, and prepare request. * diff --git a/src/index.ts b/src/index.ts index 9d20638c..a18ddef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export { RetryConfig, } from './common'; export {Gaxios, GaxiosOptions}; +export * from './interceptor'; /** * The default instance used when the `request` method is directly diff --git a/src/interceptor.ts b/src/interceptor.ts new file mode 100644 index 00000000..cf55edd1 --- /dev/null +++ b/src/interceptor.ts @@ -0,0 +1,104 @@ +// Copyright 2024 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {GaxiosError, GaxiosOptions, GaxiosResponse} from './common'; + +/** + * Interceptors that can be run for requests or responses. These interceptors run asynchronously. + */ +export interface GaxiosInterceptor { + /** + * Function to be run when applying an interceptor. + * + * @param {T} configOrResponse The current configuration or response. + * @returns {Promise} Promise that resolves to the modified set of options or response. + */ + resolved?: (configOrResponse: T) => Promise; + /** + * Function to be run if the previous call to resolved throws / rejects or the request results in an invalid status + * as determined by the call to validateStatus. + * + * @param {GaxiosError} err The error thrown from the previously called resolved function. + */ + rejected?: (err: GaxiosError) => void; +} + +/** + * Class to manage collections of GaxiosInterceptors for both requests and responses. + */ +export class GaxiosInterceptorManager + implements + Iterator | null>, + Iterable | null> +{ + #interceptorQueue: Array | null>; + #index: number; + + constructor() { + this.#interceptorQueue = []; + this.#index = 0; + } + + [Symbol.iterator](): Iterator | null> { + return this; + } + + next(): IteratorResult< + GaxiosInterceptor | null, + GaxiosInterceptor | null + > { + const value = + this.#index < this.#interceptorQueue.length + ? this.#interceptorQueue[this.#index] + : undefined; + + return this.#index++ >= this.#interceptorQueue.length + ? ({ + done: true, + value, + } as IteratorReturnResult | null>) + : ({ + done: false, + value, + } as IteratorYieldResult | null>); + } + + /** + * Adds an interceptor to the queue. + * + * @param {GaxiosInterceptor} interceptor the interceptor to be added. + * + * @returns {number} an identifier that can be used to remove the interceptor. + */ + addInterceptor(interceptor: GaxiosInterceptor): number { + return this.#interceptorQueue.push(interceptor) - 1; + } + + /** + * Removes an interceptor from the queue. + * + * @param {number} id the previously id of the interceptor to remove. + */ + removeInterceptor(id: number) { + if (this.#interceptorQueue[id]) { + this.#interceptorQueue[id] = null; + } + } + + /** + * Removes all interceptors from the queue. + */ + removeAll() { + this.#interceptorQueue = []; + } +} diff --git a/test/test.getch.ts b/test/test.getch.ts index 353ac961..b25f91c0 100644 --- a/test/test.getch.ts +++ b/test/test.getch.ts @@ -1102,3 +1102,236 @@ describe('🍂 defaults & instances', () => { }); }); }); + +describe('interceptors', () => { + describe('request', () => { + it('should invoke a request interceptor when one is provided', async () => { + const scope = nock(url) + .matchHeader('hello', 'world') + .get('/') + .reply(200, {}); + const instance = new Gaxios(); + instance.interceptors.request.addInterceptor({ + resolved: config => { + config.headers = {hello: 'world'}; + return Promise.resolve(config); + }, + }); + await instance.request({url}); + scope.done(); + }); + + it('should not invoke a request interceptor after it is removed', async () => { + const scope = nock(url).persist().get('/').reply(200, {}); + const spyFunc = sinon.fake( + () => + Promise.resolve({ + url, + validateStatus: () => { + return true; + }, + }) as unknown as Promise + ); + const instance = new Gaxios(); + const id = instance.interceptors.request.addInterceptor({ + resolved: spyFunc, + }); + await instance.request({url}); + instance.interceptors.request.removeInterceptor(id); + await instance.request({url}); + scope.done(); + assert.strictEqual(spyFunc.callCount, 1); + }); + + it('should invoke multiple request interceptors in the order they were added', async () => { + const scope = nock(url) + .matchHeader('foo', 'bar') + .matchHeader('bar', 'baz') + .matchHeader('baz', 'buzz') + .get('/') + .reply(200, {}); + const instance = new Gaxios(); + instance.interceptors.request.addInterceptor({ + resolved: config => { + config.headers!['foo'] = 'bar'; + return Promise.resolve(config); + }, + }); + instance.interceptors.request.addInterceptor({ + resolved: config => { + assert.strictEqual(config.headers!['foo'], 'bar'); + config.headers!['bar'] = 'baz'; + return Promise.resolve(config); + }, + }); + instance.interceptors.request.addInterceptor({ + resolved: config => { + assert.strictEqual(config.headers!['foo'], 'bar'); + assert.strictEqual(config.headers!['bar'], 'baz'); + config.headers!['baz'] = 'buzz'; + return Promise.resolve(config); + }, + }); + await instance.request({url, headers: {}}); + scope.done(); + }); + + it('should not invoke a any request interceptors after they are removed', async () => { + const scope = nock(url).persist().get('/').reply(200, {}); + const spyFunc = sinon.fake( + () => + Promise.resolve({ + url, + validateStatus: () => { + return true; + }, + }) as unknown as Promise + ); + const instance = new Gaxios(); + instance.interceptors.request.addInterceptor({ + resolved: spyFunc, + }); + instance.interceptors.request.addInterceptor({ + resolved: spyFunc, + }); + instance.interceptors.request.addInterceptor({ + resolved: spyFunc, + }); + await instance.request({url}); + instance.interceptors.request.removeAll(); + await instance.request({url}); + scope.done(); + assert.strictEqual(spyFunc.callCount, 3); + }); + + it('should invoke the rejected function when a previous request interceptor rejects', async () => { + const instance = new Gaxios(); + instance.interceptors.request.addInterceptor({ + resolved: () => { + throw new Error('Something went wrong'); + }, + }); + instance.interceptors.request.addInterceptor({ + resolved: config => { + config.headers = {hello: 'world'}; + return Promise.resolve(config); + }, + rejected: err => { + assert.strictEqual(err.message, 'Something went wrong'); + }, + }); + // Because the options wind up being invalid the call will reject with a URL problem. + assert.rejects(instance.request({url})); + }); + }); + + describe('response', () => { + it('should invoke a response interceptor when one is provided', async () => { + const scope = nock(url).get('/').reply(200, {}); + const instance = new Gaxios(); + instance.interceptors.response.addInterceptor({ + resolved(response) { + response.headers['hello'] = 'world'; + return Promise.resolve(response); + }, + }); + const resp = await instance.request({url}); + scope.done(); + assert.strictEqual(resp.headers['hello'], 'world'); + }); + + it('should not invoke a response interceptor after it is removed', async () => { + const scope = nock(url).persist().get('/').reply(200, {}); + const spyFunc = sinon.fake( + () => + Promise.resolve({ + url, + validateStatus: () => { + return true; + }, + }) as unknown as Promise + ); + const instance = new Gaxios(); + const id = instance.interceptors.response.addInterceptor({ + resolved: spyFunc, + }); + await instance.request({url}); + instance.interceptors.response.removeInterceptor(id); + await instance.request({url}); + scope.done(); + assert.strictEqual(spyFunc.callCount, 1); + }); + + it('should invoke multiple response interceptors in the order they were added', async () => { + const scope = nock(url).get('/').reply(200, {}); + const instance = new Gaxios(); + instance.interceptors.response.addInterceptor({ + resolved: response => { + response.headers!['foo'] = 'bar'; + return Promise.resolve(response); + }, + }); + instance.interceptors.response.addInterceptor({ + resolved: response => { + assert.strictEqual(response.headers!['foo'], 'bar'); + response.headers!['bar'] = 'baz'; + return Promise.resolve(response); + }, + }); + instance.interceptors.response.addInterceptor({ + resolved: response => { + assert.strictEqual(response.headers!['foo'], 'bar'); + assert.strictEqual(response.headers!['bar'], 'baz'); + response.headers!['baz'] = 'buzz'; + return Promise.resolve(response); + }, + }); + const resp = await instance.request({url, headers: {}}); + scope.done(); + assert.strictEqual(resp.headers['foo'], 'bar'); + assert.strictEqual(resp.headers['bar'], 'baz'); + assert.strictEqual(resp.headers['baz'], 'buzz'); + }); + + it('should not invoke a any response interceptors after they are removed', async () => { + const scope = nock(url).persist().get('/').reply(200, {}); + const spyFunc = sinon.fake( + () => + Promise.resolve({ + url, + validateStatus: () => { + return true; + }, + }) as unknown as Promise + ); + const instance = new Gaxios(); + instance.interceptors.response.addInterceptor({ + resolved: spyFunc, + }); + instance.interceptors.response.addInterceptor({ + resolved: spyFunc, + }); + instance.interceptors.response.addInterceptor({ + resolved: spyFunc, + }); + await instance.request({url}); + instance.interceptors.response.removeAll(); + await instance.request({url}); + scope.done(); + assert.strictEqual(spyFunc.callCount, 3); + }); + + it('should invoke the rejected function when a request has an error', async () => { + const scope = nock(url).get('/').reply(404, {}); + const instance = new Gaxios(); + instance.interceptors.response.addInterceptor({ + rejected: err => { + assert.strictEqual(err.status, 404); + }, + }); + + await instance.request({url}); + scope.done(); + }); + }); +}); diff --git a/test/test.index.ts b/test/test.index.ts index ac22903f..de6eab4b 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -19,5 +19,6 @@ describe('📝 main exports', () => { it('should export all the types', () => { assert(main.Gaxios); assert(main.GaxiosError); + assert(main.GaxiosInterceptorManager); }); });