Skip to content

Commit

Permalink
feat: add request and response interceptors (#619)
Browse files Browse the repository at this point in the history
* feat: add request and response interceptors

* pin compodoc due to unsupported node version in downstream dep

* separate functions to apply request and response interceptors

* Use map instead of array

* update docs

* use set instead of map
  • Loading branch information
ddelgrosso1 authored May 15, 2024
1 parent 1d31567 commit 059fe77
Show file tree
Hide file tree
Showing 6 changed files with 340 additions and 2 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^1.1.9",
"@compodoc/compodoc": "1.1.21",
"@types/cors": "^2.8.6",
"@types/express": "^4.16.1",
"@types/extend": "^3.0.1",
Expand Down
66 changes: 65 additions & 1 deletion src/gaxios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -74,12 +75,24 @@ export class Gaxios {
*/
defaults: GaxiosOptions;

/**
* Interceptors
*/
interceptors: {
request: GaxiosInterceptorManager<GaxiosOptions>;
response: GaxiosInterceptorManager<GaxiosResponse>;
};

/**
* 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(),
};
}

/**
Expand All @@ -88,7 +101,8 @@ export class Gaxios {
*/
async request<T = any>(opts: GaxiosOptions = {}): GaxiosPromise<T> {
opts = await this.#prepareRequest(opts);
return this._request(opts);
opts = await this.#applyRequestInterceptors(opts);
return this.#applyResponseInterceptors(this._request(opts));
}

private async _defaultAdapter<T>(
Expand Down Expand Up @@ -230,6 +244,56 @@ export class Gaxios {
return true;
}

/**
* Applies the request interceptors. The request interceptors are applied after the
* call to prepareRequest is completed.
*
* @param {GaxiosOptions} options The current set of options.
*
* @returns {Promise<GaxiosOptions>} Promise that resolves to the set of options or response after interceptors are applied.
*/
async #applyRequestInterceptors(
options: GaxiosOptions
): Promise<GaxiosOptions> {
let promiseChain = Promise.resolve(options);

for (const interceptor of this.interceptors.request.values()) {
if (interceptor) {
promiseChain = promiseChain.then(
interceptor.resolved,
interceptor.rejected
) as Promise<GaxiosOptions>;
}
}

return promiseChain;
}

/**
* Applies the response interceptors. The response interceptors are applied after the
* call to request is made.
*
* @param {GaxiosOptions} options The current set of options.
*
* @returns {Promise<GaxiosOptions>} Promise that resolves to the set of options or response after interceptors are applied.
*/
async #applyResponseInterceptors(
response: GaxiosResponse | Promise<GaxiosResponse>
) {
let promiseChain = Promise.resolve(response);

for (const interceptor of this.interceptors.response.values()) {
if (interceptor) {
promiseChain = promiseChain.then(
interceptor.resolved,
interceptor.rejected
) as Promise<GaxiosResponse>;
}
}

return promiseChain;
}

/**
* Validates the options, merges them with defaults, and prepare request.
*
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
RetryConfig,
} from './common';
export {Gaxios, GaxiosOptions};
export * from './interceptor';

/**
* The default instance used when the `request` method is directly
Expand Down
41 changes: 41 additions & 0 deletions src/interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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<T extends GaxiosOptions | GaxiosResponse> {
/**
* Function to be run when applying an interceptor.
*
* @param {T} configOrResponse The current configuration or response.
* @returns {Promise<T>} Promise that resolves to the modified set of options or response.
*/
resolved?: (configOrResponse: T) => Promise<T>;
/**
* 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<
T extends GaxiosOptions | GaxiosResponse,
> extends Set<GaxiosInterceptor<T> | null> {}
231 changes: 231 additions & 0 deletions test/test.getch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,3 +1102,234 @@ 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.add({
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<GaxiosOptions>
);
const instance = new Gaxios();
const interceptor = {resolved: spyFunc};
instance.interceptors.request.add(interceptor);
await instance.request({url});
instance.interceptors.request.delete(interceptor);
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.add({
resolved: config => {
config.headers!['foo'] = 'bar';
return Promise.resolve(config);
},
});
instance.interceptors.request.add({
resolved: config => {
assert.strictEqual(config.headers!['foo'], 'bar');
config.headers!['bar'] = 'baz';
return Promise.resolve(config);
},
});
instance.interceptors.request.add({
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<GaxiosOptions>
);
const instance = new Gaxios();
instance.interceptors.request.add({
resolved: spyFunc,
});
instance.interceptors.request.add({
resolved: spyFunc,
});
instance.interceptors.request.add({
resolved: spyFunc,
});
await instance.request({url});
instance.interceptors.request.clear();
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.add({
resolved: () => {
throw new Error('Something went wrong');
},
});
instance.interceptors.request.add({
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.add({
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<GaxiosResponse>
);
const instance = new Gaxios();
const interceptor = {resolved: spyFunc};
instance.interceptors.response.add(interceptor);
await instance.request({url});
instance.interceptors.response.delete(interceptor);
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.add({
resolved: response => {
response.headers!['foo'] = 'bar';
return Promise.resolve(response);
},
});
instance.interceptors.response.add({
resolved: response => {
assert.strictEqual(response.headers!['foo'], 'bar');
response.headers!['bar'] = 'baz';
return Promise.resolve(response);
},
});
instance.interceptors.response.add({
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<GaxiosResponse>
);
const instance = new Gaxios();
instance.interceptors.response.add({
resolved: spyFunc,
});
instance.interceptors.response.add({
resolved: spyFunc,
});
instance.interceptors.response.add({
resolved: spyFunc,
});
await instance.request({url});
instance.interceptors.response.clear();
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.add({
rejected: err => {
assert.strictEqual(err.status, 404);
},
});

await instance.request({url});
scope.done();
});
});
});
Loading

0 comments on commit 059fe77

Please sign in to comment.