Skip to content

Commit

Permalink
feat(TDP-12106): rework http interceptors to access request object an…
Browse files Browse the repository at this point in the history
…d return promise (#4899)
  • Loading branch information
Guillaume NICOLAS authored Oct 9, 2023
1 parent 759de74 commit 72a8f20
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-birds-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@talend/http': minor
---

feat(TDP-12106): improve interceptors to return a promise, have access to request and a business context from caller
37 changes: 37 additions & 0 deletions packages/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,40 @@ async function test() {
const response = await http.get('/api/v1/my-resource');
}
```

## Interceptors

You can add global response interceptors to catch or modify responses before resolve.

```es6
import { addHttpResponseInterceptor, http, HTTP_METHODS } from '@talend/http';
import type { TalendRequest } from '@talend/http';

addHttpResponseInterceptor('my-interceptor', async (response: Response, request: TalendRequest) => {
if (request.method === HTTP_METHODS.GET) {
// your custom logic here
}

return response;
});
```

You can add multiple interceptors. Each will be called in the order of registration and will receive the same request parameter, but response parameter will be the one returned by previous interceptor. If interceptor returns void, then it'll return received response.

Once your interceptor is not needed anymore, you can unregister it with `removeHttpResponseInterceptor` function of `@talend/http` package.

You can identify some requests in interceptor by using `context` property in fetch function config:

```es6
import { addHttpResponseInterceptor, http, HTTP_METHODS } from '@talend/http';

http.get('/api/v1/data', { context: { intercept: true } });

addHttpResponseInterceptor('my-interceptor', async (response: Response, request: TalendRequest) => {
const { context } = request;
if (request.method === HTTP_METHODS.GET && context.intercept) {
// your custom logic here
}
return response;
});
```
68 changes: 68 additions & 0 deletions packages/http/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
removeHttpResponseInterceptor,
setDefaultConfig,
setDefaultLanguage,
applyInterceptors,
} from './config';
import { HTTP_METHODS, HTTP_STATUS } from './http.constants';
import { TalendRequest } from './http.types';

describe('Configuration service', () => {
describe('setDefaultLanguage', () => {
Expand Down Expand Up @@ -93,5 +96,70 @@ describe('Configuration service', () => {
);
expect(HTTP_RESPONSE_INTERCEPTORS).toEqual({ myInterceptor2: interceptor2 });
});
it('should apply all interceptors', async () => {
const request: TalendRequest = {
url: '/api/v1/data',
method: HTTP_METHODS.GET,
};
const response = {
ok: true,
status: HTTP_STATUS.OK,
body: [1, 2, 3],
} as unknown as Response;

const interceptor1 = jest
.fn()
.mockImplementation((resp, _) => Promise.resolve({ ...resp, body: [...resp.body, 4] }));
addHttpResponseInterceptor('interceptor-1', interceptor1);

const interceptor2 = jest.fn().mockImplementation((resp, req) =>
Promise.resolve({
...resp,
body: { interceptor: `interceptor2-${req.method}`, original: resp.body },
}),
);
addHttpResponseInterceptor('interceptor-2', interceptor2);

const interceptedResponse = await applyInterceptors(request, response);

expect(interceptor1).toHaveBeenCalledWith(response, request);
expect(interceptor2).toHaveBeenLastCalledWith(
expect.objectContaining({ body: [1, 2, 3, 4] }),
request,
);
expect(interceptedResponse).toEqual({
...response,
body: { interceptor: 'interceptor2-GET', original: [1, 2, 3, 4] },
});
});
it('should return response if no interceptors', () => {
const request: TalendRequest = {
url: '/api/v1/data',
method: HTTP_METHODS.GET,
};
const response = {
ok: true,
status: HTTP_STATUS.OK,
body: [1, 2, 3],
} as unknown as Response;

expect(applyInterceptors(request, response)).resolves.toEqual(response);
});
it('should return response if interceptor returns void', async () => {
const request: TalendRequest = {
url: '/api/v1/data',
method: HTTP_METHODS.GET,
};
const response = {
ok: true,
status: HTTP_STATUS.OK,
body: [1, 2, 3],
} as unknown as Response;
const interceptor = jest.fn().mockImplementation(() => {});
addHttpResponseInterceptor('interceptor', interceptor);
const gotResponse = await applyInterceptors(request, response);
expect(gotResponse).toEqual(response);
expect(interceptor).toHaveBeenCalledWith(response, request);
});
});
});
19 changes: 13 additions & 6 deletions packages/http/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TalendRequestInit } from './http.types';
import { TalendRequest, TalendRequestInit } from './http.types';

/**
* Storage point for the doc setup using `setDefaultConfig`
Expand All @@ -7,12 +7,11 @@ export const HTTP: { defaultConfig?: TalendRequestInit | null } = {
defaultConfig: null,
};

export const HTTP_RESPONSE_INTERCEPTORS: Record<string, (response: Response) => void> = {};
export type Interceptor = (response: Response, request: TalendRequest) => Promise<Response> | void;

export function addHttpResponseInterceptor(
name: string,
interceptor: (response: Response) => void,
) {
export const HTTP_RESPONSE_INTERCEPTORS: Record<string, Interceptor> = {};

export function addHttpResponseInterceptor(name: string, interceptor: Interceptor) {
if (HTTP_RESPONSE_INTERCEPTORS[name]) {
throw new Error(`Interceptor ${name} already exists`);
}
Expand All @@ -26,6 +25,14 @@ export function removeHttpResponseInterceptor(name: string) {
delete HTTP_RESPONSE_INTERCEPTORS[name];
}

export function applyInterceptors(request: TalendRequest, response: Response): Promise<Response> {
return Object.values(HTTP_RESPONSE_INTERCEPTORS).reduce(
(promise, interceptor) =>
promise.then(resp => interceptor(resp, request) || Promise.resolve(response)),
Promise.resolve(response),
);
}

/**
* setDefaultHeader - define a default config to use with the saga http
* this default config is stored in this module for the whole application
Expand Down
60 changes: 58 additions & 2 deletions packages/http/src/http.common.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import fetchMock from 'fetch-mock';
import { Response, Headers } from 'node-fetch';

import { HTTP, getDefaultConfig, setDefaultConfig } from './config';
import {
HTTP,
getDefaultConfig,
setDefaultConfig,
HTTP_RESPONSE_INTERCEPTORS,
addHttpResponseInterceptor,
} from './config';
import { httpFetch, handleBody, encodePayload, handleHttpResponse } from './http.common';
import { HTTP_METHODS, HTTP_STATUS } from './http.constants';
import { TalendHttpError } from './http.types';
Expand Down Expand Up @@ -48,7 +54,10 @@ describe('handleBody', () => {

const blob = jest.fn(() => Promise.resolve());

await handleBody({ blob, headers } as any);
await handleBody({
headers,
clone: jest.fn().mockReturnValue({ blob }),
} as any);

expect(blob).toHaveBeenCalled();
});
Expand All @@ -71,6 +80,12 @@ describe('handleBody', () => {
expect(result.data).toBe('');
});

it("should manage response's body and return a clone with unused body", async () => {
const result = await handleBody(new Response('ok') as any);
expect(result.data).toBe('ok');
expect(result.response.bodyUsed).toBe(false);
});

describe('#handleHttpResponse', () => {
it('should handle the response with 2xx code', async () => {
const headers = new Headers();
Expand Down Expand Up @@ -328,3 +343,44 @@ describe('#httpFetch', () => {
expect(mockCalls[0][1]?.headers).toEqual({ Accept: 'application/json' });
});
});

describe('#httpFetch with interceptors', () => {
beforeEach(() => {
for (const key in HTTP_RESPONSE_INTERCEPTORS) {
if (HTTP_RESPONSE_INTERCEPTORS.hasOwnProperty(key)) {
delete HTTP_RESPONSE_INTERCEPTORS[key];
}
}
});

afterEach(() => {
fetchMock.restore();
});

it('should call interceptor', async () => {
const interceptor = jest.fn().mockImplementation((res, _) => res);
addHttpResponseInterceptor('interceptor', interceptor);

const url = '/foo';
fetchMock.mock(url, { body: defaultBody, status: 200 });

await httpFetch(url, {}, HTTP_METHODS.GET, {});
expect(interceptor).toHaveBeenCalled();
});

it('should have access to context in interceptor', async () => {
const interceptor = jest.fn().mockImplementation((res, _) => res);
addHttpResponseInterceptor('interceptor', interceptor);

const url = '/foo';
const context = { async: true };
const response = { body: defaultBody, status: 200 };
fetchMock.mock(url, response);

await httpFetch(url, { context }, HTTP_METHODS.GET, {});
expect(interceptor).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ url, context, method: HTTP_METHODS.GET }),
);
});
});
33 changes: 19 additions & 14 deletions packages/http/src/http.common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HTTP, HTTP_RESPONSE_INTERCEPTORS } from './config';
import { applyInterceptors, HTTP } from './config';
import { mergeCSRFToken } from './csrfHandling';
import { HTTP_STATUS, testHTTPCode } from './http.constants';
import { TalendHttpResponse, TalendRequestInit } from './http.types';
Expand Down Expand Up @@ -40,17 +40,18 @@ export function encodePayload(headers: HeadersInit, payload: any) {
* @return {Promise} A promise that resolves with the result of parsing the body
*/
export async function handleBody(response: Response) {
const clonedResponse = response.clone();
const { headers } = response;
const contentType = headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
return response.json().then(data => ({ data, response }));
return clonedResponse.json().then(data => ({ data, response }));
}

if (contentType && contentType.includes('application/zip')) {
return response.blob().then(data => ({ data, response }));
return clonedResponse.blob().then(data => ({ data, response }));
}

return response.text().then(data => ({ data, response }));
return clonedResponse.text().then(data => ({ data, response }));
}

/**
Expand Down Expand Up @@ -118,17 +119,21 @@ export async function httpFetch<T>(
},
};

const response = await fetch(
url,
handleCSRFToken({
...params,
body: encodePayload(params.headers || {}, payload),
}),
);

Object.values(HTTP_RESPONSE_INTERCEPTORS).forEach(interceptor => {
interceptor(response);
const { context, ...init } = handleCSRFToken({
...params,
body: encodePayload(params.headers || {}, payload),
});

const response = await fetch(url, init).then(resp =>
applyInterceptors(
{
url,
...init,
context,
},
resp,
),
);

return handleHttpResponse(response);
}
5 changes: 5 additions & 0 deletions packages/http/src/http.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ export type TalendRequestInitSecurity = {

export interface TalendRequestInit extends RequestInit {
security?: TalendRequestInitSecurity;
context?: Record<string, unknown>;
}

export type TalendRequest = {
url: string;
} & TalendRequestInit;

export interface TalendHttpError<T> extends Error {
response: Response;
data: T;
Expand Down

0 comments on commit 72a8f20

Please sign in to comment.