diff --git a/packages/clients/src/index.ts b/packages/clients/src/index.ts index e37e9e8f6..4d31a12d9 100644 --- a/packages/clients/src/index.ts +++ b/packages/clients/src/index.ts @@ -2,6 +2,12 @@ export type { WaitForOptions, WaitForStopCondition, } from './internal/async/interval-retrier' +export type { + NetworkInterceptors, + RequestInterceptor, + ResponseInterceptor, + ResponseErrorInterceptor, +} from './internal/interceptors/types' export { enableConsoleLogger, setLogger } from './internal/logger' export type { Logger } from './internal/logger/logger' export { createClient, createAdvancedClient } from './scw/client' @@ -9,6 +15,7 @@ export type { Client } from './scw/client' export type { Profile } from './scw/client-ini-profile' export type { Settings } from './scw/client-settings' export { + withAdditionalInterceptors, withDefaultPageSize, withHTTPClient, withProfile, diff --git a/packages/clients/src/internal/interceptors/__tests__/composer.ts b/packages/clients/src/internal/interceptors/__tests__/composer.ts new file mode 100644 index 000000000..f0674a290 --- /dev/null +++ b/packages/clients/src/internal/interceptors/__tests__/composer.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from '@jest/globals' +import { + composeRequestInterceptors, + composeResponseErrorInterceptors, +} from '../composer' + +describe('composeRequestInterceptors', () => { + it('modifies the request header', async () => { + const interceptor = composeRequestInterceptors([ + ({ request }): Request => { + const clone = request.clone() + clone.headers.set('new-header', '42') + + return clone + }, + ]) + + return expect( + interceptor(new Request('https://api.scaleway.com')).then(obj => + obj.headers.get('new-header'), + ), + ).resolves.toBe('42') + }) +}) + +describe('composeResponseErrorInterceptors', () => { + it('passes the error to all interceptors if they all throw', () => { + class NumberError extends Error { + counter: number + + constructor(obj: number) { + super() + this.counter = obj + Object.setPrototypeOf(this, NumberError.prototype) + } + } + + const interceptors = composeResponseErrorInterceptors([ + ({ error }): Promise => { + throw error instanceof NumberError + ? new NumberError(error.counter + 1) + : error + }, + ({ error }): Promise => { + throw error instanceof NumberError + ? new NumberError(error.counter + 2) + : error + }, + ])(new Request('https://api.scaleway.com'), new NumberError(42)) + + return expect(interceptors).rejects.toThrow(new NumberError(45)) + }) + + it('stops at the second interceptor (amongst three) if it resolves', () => { + const interceptors = composeResponseErrorInterceptors([ + ({ error }): Promise => { + throw error + }, + (): Promise => Promise.resolve(42), + ({ error }): Promise => { + throw error + }, + ])(new Request('https://api.scaleway.com'), new TypeError('')) + + return expect(interceptors).resolves.toBe(42) + }) + + it('throws the last processed error', () => { + const interceptors = composeResponseErrorInterceptors([ + ({ error }): Promise => { + throw error + }, + (): Promise => { + throw new TypeError('second error') + }, + ])(new Request('https://api.scaleway.com'), new TypeError('first error')) + + return expect(interceptors).rejects.toThrow(new TypeError('second error')) + }) +}) diff --git a/packages/clients/src/internal/interceptors/__tests__/request.ts b/packages/clients/src/internal/interceptors/__tests__/helpers.ts similarity index 53% rename from packages/clients/src/internal/interceptors/__tests__/request.ts rename to packages/clients/src/internal/interceptors/__tests__/helpers.ts index 697748b53..e623016e5 100644 --- a/packages/clients/src/internal/interceptors/__tests__/request.ts +++ b/packages/clients/src/internal/interceptors/__tests__/helpers.ts @@ -1,32 +1,41 @@ import { describe, expect, it } from '@jest/globals' -import { addAsyncHeaderInterceptor, addHeaderInterceptor } from '../request' +import { addAsyncHeaderInterceptor, addHeaderInterceptor } from '../helpers' describe('addHeaderInterceptor', () => { it('insertsnothing if value is undefined', async () => { - const req = new Request('https://api.scaleway.com/my/path') - const updatedReq = await addHeaderInterceptor('my-key', undefined)(req) + const request = new Request('https://api.scaleway.com/my/path') + const updatedReq = await addHeaderInterceptor( + 'my-key', + undefined, + )({ request }) expect(updatedReq.headers.has('my-key')).toBe(false) }) it('inserts 1 key/value in the request', async () => { - const req = new Request('https://api.scaleway.com/my/path') - const updatedReq = await addHeaderInterceptor('my-key', 'my-value')(req) + const request = new Request('https://api.scaleway.com/my/path') + const updatedReq = await addHeaderInterceptor( + 'my-key', + 'my-value', + )({ request }) expect(updatedReq.headers.get('my-key')).toBe('my-value') }) it(`desn't modify the input request`, async () => { - const req = new Request('https://api.scaleway.com/my/path') - const updatedReq = await addHeaderInterceptor('my-key', 'my-value')(req) - expect(req).not.toStrictEqual(updatedReq) + const request = new Request('https://api.scaleway.com/my/path') + const updatedReq = await addHeaderInterceptor( + 'my-key', + 'my-value', + )({ request }) + expect(request).not.toStrictEqual(updatedReq) }) }) describe('addAsyncHeaderInterceptor', () => { it('inserts 1 key/value in the request', async () => { - const req = new Request('https://api.scaleway.com/my/path') + const request = new Request('https://api.scaleway.com/my/path') const updatedReq = await addAsyncHeaderInterceptor('my-key', async () => Promise.resolve('my-value'), - )(req) + )({ request }) expect(updatedReq.headers.get('my-key')).toBe('my-value') }) }) diff --git a/packages/clients/src/internal/interceptors/__tests__/interceptor.ts b/packages/clients/src/internal/interceptors/__tests__/interceptor.ts deleted file mode 100644 index dc49ada27..000000000 --- a/packages/clients/src/internal/interceptors/__tests__/interceptor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from '@jest/globals' -import type { Interceptor } from '../interceptor' -import { composeInterceptors } from '../interceptor' - -type StringArrayInterceptor = Interceptor - -const addStrInterceptor = - (str: string): StringArrayInterceptor => - (list: Readonly): string[] => - list.concat(str) - -const removeFirstStrInterceptor = - (): StringArrayInterceptor => - (list: Readonly): string[] => - list.slice(1) - -describe('applyInterceptors', () => { - it('calls one interceptor', async () => { - const interceptor = composeInterceptors([addStrInterceptor('Fusion')]) - expect(await interceptor(['Tree', 'Animal'])).toStrictEqual([ - 'Tree', - 'Animal', - 'Fusion', - ]) - }) - - it('compose two interceptors', async () => { - const interceptor = composeInterceptors([ - addStrInterceptor('Fusion'), - removeFirstStrInterceptor(), - ]) - expect(await interceptor(['Tree', 'Animal'])).toStrictEqual([ - 'Animal', - 'Fusion', - ]) - }) -}) diff --git a/packages/clients/src/internal/interceptors/composer.ts b/packages/clients/src/internal/interceptors/composer.ts new file mode 100644 index 000000000..94373b5d7 --- /dev/null +++ b/packages/clients/src/internal/interceptors/composer.ts @@ -0,0 +1,61 @@ +import type { + RequestInterceptor, + ResponseErrorInterceptor, + ResponseInterceptor, +} from './types' + +/** + * Composes request interceptors. + * + * @param interceptors - A list of request interceptors + * @returns An async composed interceptor + * + * @internal + */ +export const composeRequestInterceptors = + (interceptors: RequestInterceptor[]) => + async (request: Request): Promise => + interceptors.reduce( + async (asyncResult, interceptor): Promise => + interceptor({ request: await asyncResult }), + Promise.resolve(request), + ) + +/** + * Composes response interceptors. + * + * @param interceptors - A list of response interceptors + * @returns An async composed interceptor + * + * @internal + */ +export const composeResponseInterceptors = + (interceptors: ResponseInterceptor[]) => + async (response: Response): Promise => + interceptors.reduce( + async (asyncResult, interceptor): Promise => + interceptor({ response: await asyncResult }), + Promise.resolve(response), + ) + +/** + * Compose response error interceptors. + * + * @internal + */ +export const composeResponseErrorInterceptors = + (interceptors: ResponseErrorInterceptor[]) => + async (request: Request, error: unknown): Promise => { + let prevError = error + for (const interceptor of interceptors) { + try { + const res = await interceptor({ request, error: prevError }) + + return res + } catch (err) { + prevError = err + } + } + + throw prevError + } diff --git a/packages/clients/src/internal/interceptors/request.ts b/packages/clients/src/internal/interceptors/helpers.ts similarity index 84% rename from packages/clients/src/internal/interceptors/request.ts rename to packages/clients/src/internal/interceptors/helpers.ts index 5b8f42509..bff3af14b 100644 --- a/packages/clients/src/internal/interceptors/request.ts +++ b/packages/clients/src/internal/interceptors/helpers.ts @@ -1,7 +1,4 @@ -import type { Interceptor } from './interceptor' - -/** Request Interceptor. */ -export type RequestInterceptor = Interceptor +import type { RequestInterceptor } from './types' /** * Adds an header to a request through an interceptor. @@ -14,7 +11,7 @@ export type RequestInterceptor = Interceptor */ export const addHeaderInterceptor = (key: string, value?: string): RequestInterceptor => - request => { + ({ request }) => { const clone = request.clone() if (value !== undefined) { clone.headers.append(key, value) diff --git a/packages/clients/src/internal/interceptors/interceptor.ts b/packages/clients/src/internal/interceptors/interceptor.ts deleted file mode 100644 index fd2dab2fc..000000000 --- a/packages/clients/src/internal/interceptors/interceptor.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface Interceptor { - (instance: Readonly): T | Promise -} - -/** - * Composes interceptors. - * - * @param interceptors - A list of interceptors (that modify an object instance) - * @returns An async composed interceptor - * - * @internal - */ -export const composeInterceptors = - (interceptors: Interceptor[]) => - async (instance: T): Promise => - interceptors.reduce( - async (asyncResult, interceptor) => interceptor(await asyncResult), - new Promise(resolve => { - resolve(instance) - }), - ) diff --git a/packages/clients/src/internal/interceptors/response.ts b/packages/clients/src/internal/interceptors/response.ts deleted file mode 100644 index 4e78d1c83..000000000 --- a/packages/clients/src/internal/interceptors/response.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { Interceptor } from './interceptor' - -/** Response Interceptor. */ -export type ResponseInterceptor = Interceptor diff --git a/packages/clients/src/internal/interceptors/types.ts b/packages/clients/src/internal/interceptors/types.ts new file mode 100644 index 000000000..98465b398 --- /dev/null +++ b/packages/clients/src/internal/interceptors/types.ts @@ -0,0 +1,87 @@ +/** + * Defines the interceptor for a `Request`. + * This allows you to intercept requests before starting. + * + * @example + * Adds a custom header to a request: + * ``` + * const addCustomHeaderInterceptor + * ({ key, value }: { key: string; value: string }): RequestInterceptor => + * ({ request }) => { + * const clone = request.clone() + * clone.headers.set(key, value) + * + * return clone + * } + * ``` + * + * @public + */ +export interface RequestInterceptor { + ({ request }: { request: Readonly }): Request | Promise +} + +/** + * Defines the interceptor for a `Response`. + * This allows you to intercept responses before unmarshalling. + * + * @example + * Adds a delay before sending the response: + * ``` + * const addDelayInterceptor: ResponseInterceptor = ({ response }) => + * new Promise(resolve => { + * setTimeout(() => resolve(response), 1000) + * }) + * ``` + * + * @public + */ +export interface ResponseInterceptor { + ({ response }: { response: Readonly }): Response | Promise +} + +/** + * Defines the interceptor for a `Response` error. + * This allows you to intercept a response error before exiting the whole process. + * You can either rethrow an error, and resolve with a different response. + * + * @remarks + * You must return either: + * 1. An error (`throw error` or `Promise.reject(error)`) + * 2. Data (directly, or via a Promise) + * + * @example + * Reports error to tracking service: + * ``` + * const reportErrorToTrackingService: ResponseErrorInterceptor = async ({ + * request, + * error, + * }: { + * request: Request + * error: unknown + * }) => { + * await sendErrorToErrorService(request, error) + * throw error + * } + * ``` + * + * @public + */ +export interface ResponseErrorInterceptor { + ({ request, error }: { request: Request; error: unknown }): + | unknown + | Promise +} + +/** + * Defines the network interceptors. + * Please check the documentation of {@link RequestInterceptor}, + * {@link ResponseInterceptor} and {@link ResponseErrorInterceptor} for examples. + * + * @public + */ +export type NetworkInterceptors = { + request?: RequestInterceptor + response?: ResponseInterceptor + responseError?: ResponseErrorInterceptor +} diff --git a/packages/clients/src/internals.ts b/packages/clients/src/internals.ts index 5b9053b9f..66c8c75f5 100644 --- a/packages/clients/src/internals.ts +++ b/packages/clients/src/internals.ts @@ -1,9 +1,10 @@ export { isJSONObject } from './helpers/json' export { waitForResource } from './internal/async/interval-retrier' -export type { RequestInterceptor } from './internal/interceptors/request' -export type { ResponseInterceptor } from './internal/interceptors/response' +export { addAsyncHeaderInterceptor } from './internal/interceptors/helpers' export { API } from './scw/api' +/* eslint-disable deprecation/deprecation */ export { authenticateWithSessionToken } from './scw/auth' +/* eslint-enable deprecation/deprecation */ export type { DefaultValues } from './scw/client-settings' export { marshalScwFile, diff --git a/packages/clients/src/scw/__tests__/auth.ts b/packages/clients/src/scw/__tests__/auth.ts index d7e918669..71b3a7236 100644 --- a/packages/clients/src/scw/__tests__/auth.ts +++ b/packages/clients/src/scw/__tests__/auth.ts @@ -67,7 +67,7 @@ describe('authenticateWithSessionToken', () => { const sourceReq = new Request('https://api.scaleway.com/my/path') const updatedReq = await authenticateWithSessionToken( (): Promise => Promise.resolve(dummyToken), - )(sourceReq) + )({ request: sourceReq }) const expectedReq = sourceReq.clone() expectedReq.headers.append('x-session-token', 'dummy') expect(updatedReq).toStrictEqual(expectedReq) @@ -82,7 +82,9 @@ describe('authenticateWithSecrets', () => { accessKey: 'SCW01234567890123456', secretKey: 'e4b83996-4c60-449a-98d2-38f5de7b4e6b', } - const updatedReq = await authenticateWithSecrets(validSecrets)(sourceReq) + const updatedReq = await authenticateWithSecrets(validSecrets)({ + request: sourceReq, + }) const expectedReq = sourceReq.clone() expectedReq.headers.append('x-auth-token', validSecrets.secretKey) expect(updatedReq).toStrictEqual(expectedReq) @@ -93,6 +95,8 @@ describe('authenticateWithSecrets', () => { accessKey: '', secretKey: '', } - expect(() => authenticateWithSecrets(invalidSecrets)(sourceReq)).toThrow() + expect(() => + authenticateWithSecrets(invalidSecrets)({ request: sourceReq }), + ).toThrow() }) }) diff --git a/packages/clients/src/scw/__tests__/client-ini-factory.ts b/packages/clients/src/scw/__tests__/client-ini-factory.ts index dd6d9b0ff..77068f89f 100644 --- a/packages/clients/src/scw/__tests__/client-ini-factory.ts +++ b/packages/clients/src/scw/__tests__/client-ini-factory.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from '@jest/globals' +import type { ClientConfig } from '../client-ini-factory' import { + withAdditionalInterceptors, withDefaultPageSize, withHTTPClient, + withLegacyInterceptors, withProfile, withUserAgent, withUserAgentSuffix, @@ -29,8 +32,7 @@ const DEFAULT_SETTINGS: Readonly = { defaultRegion: 'nl-ams', defaultZone: 'fr-par-1', httpClient: fetch, - requestInterceptors: [], - responseInterceptors: [], + interceptors: [], userAgent: 'scaleway-sdk-js/v1.0.0-beta', } @@ -53,6 +55,7 @@ describe('withProfile', () => { defaultRegion: undefined, defaultZone: undefined, httpClient: undefined, + interceptors: undefined, requestInterceptors: undefined, responseInterceptors: undefined, userAgent: undefined, @@ -67,6 +70,7 @@ describe('withProfile', () => { defaultRegion: null, defaultZone: null, httpClient: null, + interceptors: null, requestInterceptors: null, responseInterceptors: null, userAgent: null, @@ -81,6 +85,7 @@ describe('withProfile', () => { defaultRegion: '', defaultZone: '', httpClient: '', + interceptors: '', requestInterceptors: '', responseInterceptors: '', userAgent: '', @@ -95,6 +100,7 @@ describe('withProfile', () => { defaultRegion: 0, defaultZone: 0, httpClient: 0, + interceptors: 0, requestInterceptors: 0, responseInterceptors: 0, userAgent: 0, @@ -163,13 +169,18 @@ describe('withProfile', () => { }) it('modifies authentication', async () => { - const { headers } = await withProfile({ + const request = new Request(DEFAULT_SETTINGS.apiURL) + const reqInterceptor = withProfile({ accessKey: FILLED_PROFILE.accessKey, secretKey: FILLED_PROFILE.secretKey, - })(DEFAULT_SETTINGS).requestInterceptors[0]( - new Request(DEFAULT_SETTINGS.apiURL), - ) - expect(headers.get('x-auth-token')).toStrictEqual(FILLED_PROFILE.secretKey) + })(DEFAULT_SETTINGS).interceptors[0].request + expect(reqInterceptor).toBeDefined() + if (reqInterceptor) { + const { headers } = await reqInterceptor({ request }) + expect(headers.get('x-auth-token')).toStrictEqual( + FILLED_PROFILE.secretKey, + ) + } }) }) @@ -238,3 +249,67 @@ describe('withUserAgentSuffix', () => { ).toStrictEqual(JSON.stringify(expectedSettings)) }) }) + +describe('withAdditionalInterceptors', () => { + it('appends interceptors to existing ones', () => { + const oneInterProfile = withAdditionalInterceptors([ + { + request: ({ request }) => request, + }, + ])(DEFAULT_SETTINGS) + const twoInterProfile = withAdditionalInterceptors([ + { + response: ({ response }) => response, + responseError: err => err, + }, + ])(oneInterProfile) + expect(twoInterProfile.interceptors.length).toEqual(2) + expect(twoInterProfile.interceptors[1].response).toBeDefined() + expect(twoInterProfile.interceptors[1].responseError).toBeDefined() + expect(twoInterProfile.interceptors[1].request).toBeUndefined() + }) +}) + +describe('withLegacyInterceptors', () => { + it('changes nothing if no legacy interceptor', () => { + expect( + JSON.stringify(withLegacyInterceptors()(DEFAULT_SETTINGS)), + ).toStrictEqual(JSON.stringify(DEFAULT_SETTINGS)) + }) + + it('appends the legacy request and response interceptors', () => { + const legacyInterceptors: ClientConfig = (obj: Settings): Settings => ({ + ...obj, + requestInterceptors: [ + ({ request }): Request => request, + ({ request }): Request => request, + ], + responseInterceptors: [({ response }): Response => response], + }) + expect( + withLegacyInterceptors()(legacyInterceptors(DEFAULT_SETTINGS)) + .interceptors.length, + ).toBe(3) + + const legacyReqInterceptors: ClientConfig = (obj: Settings): Settings => ({ + ...obj, + requestInterceptors: [ + ({ request }): Request => request, + ({ request }): Request => request, + ], + }) + expect( + withLegacyInterceptors()(legacyReqInterceptors(DEFAULT_SETTINGS)) + .interceptors.length, + ).toBe(2) + + const legacyResInterceptors: ClientConfig = (obj: Settings): Settings => ({ + ...obj, + responseInterceptors: [({ response }): Response => response], + }) + expect( + withLegacyInterceptors()(legacyResInterceptors(DEFAULT_SETTINGS)) + .interceptors.length, + ).toBe(1) + }) +}) diff --git a/packages/clients/src/scw/__tests__/client-settings.ts b/packages/clients/src/scw/__tests__/client-settings.ts index 3d49376d5..35852c36b 100644 --- a/packages/clients/src/scw/__tests__/client-settings.ts +++ b/packages/clients/src/scw/__tests__/client-settings.ts @@ -10,6 +10,7 @@ const VALID_SETTINGS: Settings = { defaultRegion: 'fr-par', defaultZone: 'fr-par-1', httpClient: fetch, + interceptors: [], requestInterceptors: [], responseInterceptors: [], userAgent: 'scaleway-sdk-js/v1.0.0-beta', diff --git a/packages/clients/src/scw/__tests__/client.ts b/packages/clients/src/scw/__tests__/client.ts index b15f5de39..046fed9aa 100644 --- a/packages/clients/src/scw/__tests__/client.ts +++ b/packages/clients/src/scw/__tests__/client.ts @@ -36,7 +36,7 @@ describe('createAdvancedClient', () => { secretKey: '3aef5281-13eb-4705-b858-eb64dd5da24c', }), ) - expect(client.settings.requestInterceptors.length).toBe(1) + expect(client.settings.interceptors.length).toBe(1) }) it('does not mutate default requestInterceptors', () => { @@ -52,7 +52,7 @@ describe('createAdvancedClient', () => { secretKey: '3aef5281-13eb-4705-b858-eb64dd5da24c', }), ) - expect(client.settings.requestInterceptors.length).toBe(1) + expect(client.settings.interceptors.length).toBe(1) }) it('contains override from custom option', () => { diff --git a/packages/clients/src/scw/auth.ts b/packages/clients/src/scw/auth.ts index d9e76f68f..da3824ee8 100644 --- a/packages/clients/src/scw/auth.ts +++ b/packages/clients/src/scw/auth.ts @@ -1,8 +1,8 @@ -import type { RequestInterceptor } from '../internal/interceptors/request' import { addAsyncHeaderInterceptor, addHeaderInterceptor, -} from '../internal/interceptors/request' +} from '../internal/interceptors/helpers' +import type { RequestInterceptor } from '../internal/interceptors/types' import { assertValidAuthenticationSecrets } from './client-ini-profile' import type { AuthenticationSecrets } from './client-ini-profile' @@ -19,6 +19,8 @@ interface TokenAccessor { * @param getToken - The token accessor * @returns The request interceptor * + * @deprecated Please use addAsyncHeaderInterceptor instead. + * * @internal */ export const authenticateWithSessionToken = ( diff --git a/packages/clients/src/scw/client-ini-factory.ts b/packages/clients/src/scw/client-ini-factory.ts index 700850bd1..1a4114fe0 100644 --- a/packages/clients/src/scw/client-ini-factory.ts +++ b/packages/clients/src/scw/client-ini-factory.ts @@ -1,3 +1,4 @@ +import type { NetworkInterceptors } from '../index' import { authenticateWithSecrets } from './auth' import { hasAuthenticationSecrets } from './client-ini-profile' import type { Profile } from './client-ini-profile' @@ -40,9 +41,11 @@ export const withProfile = newSettings.defaultZone = profile.defaultZone } if (hasAuthenticationSecrets(profile)) { - newSettings.requestInterceptors = [ - authenticateWithSecrets(profile), - ...settings.requestInterceptors, + newSettings.interceptors = [ + { + request: authenticateWithSecrets(profile), + }, + ...newSettings.interceptors, ] } @@ -109,3 +112,78 @@ export const withUserAgentSuffix = ? `${settings.userAgent} ${userAgent}` : userAgent, }) + +/** + * Instantiates the SDK with additional interceptors. + * + * @param interceptors - The additional {@link NetworkInterceptors} interceptors + * @returns A factory {@link ClientConfig} + * + * @remarks + * It doesn't override the existing interceptors, but instead push more to the list. + * This method should be used in conjunction with the initializer `createAdvancedClient`. + * + * @example + * ``` + * withAdditionalInterceptors([ + * { + * request: ({ request }) => { + * console.log(`Do something with ${JSON.stringify(request)}`) + * return request + * }, + * response: ({ response }) => { + * console.log(`Do something with ${JSON.stringify(response)}`) + * return response + * }, + * responseError: async ({ + * request, + * error, + * }: { + * request: Request + * error: unknown + * }) => { + * console.log( + * `Do something with ${JSON.stringify(request)} and ${JSON.stringify( + * error, + * )}`, + * ) + * throw error // or return Promise.resolve(someData) + * }, + * }, + * ]) + * ``` + * + * @public + */ +export const withAdditionalInterceptors = + (interceptors: NetworkInterceptors[]) => + (settings: Readonly): Settings => ({ + ...settings, + interceptors: settings.interceptors.concat(interceptors), + }) + +/** + * Instantiates the SDK with legacy interceptors. + */ +/* eslint-disable deprecation/deprecation */ +export const withLegacyInterceptors = + () => + (settings: Readonly): Settings => { + if (!settings.requestInterceptors && !settings.responseInterceptors) { + return settings + } + const allInterceptors = settings.interceptors.concat( + (settings.requestInterceptors ?? []).map(obj => ({ + request: obj, + })), + (settings.responseInterceptors ?? []).map(obj => ({ + response: obj, + })), + ) + + return { + ...settings, + interceptors: allInterceptors, + } + } +/* eslint-enable deprecation/deprecation */ diff --git a/packages/clients/src/scw/client-settings.ts b/packages/clients/src/scw/client-settings.ts index fe90f9520..f2fe7f49b 100644 --- a/packages/clients/src/scw/client-settings.ts +++ b/packages/clients/src/scw/client-settings.ts @@ -1,5 +1,8 @@ -import type { RequestInterceptor } from '../internal/interceptors/request' -import type { ResponseInterceptor } from '../internal/interceptors/response' +import type { + NetworkInterceptors, + RequestInterceptor, + ResponseInterceptor, +} from '../internal/interceptors/types' import { isOrganizationId, isProjectId, @@ -37,12 +40,20 @@ export interface Settings extends DefaultValues { httpClient: typeof fetch /** * The Request interceptors. + * + * @deprecated Please use `interceptors` instead. */ - requestInterceptors: RequestInterceptor[] + requestInterceptors?: RequestInterceptor[] /** * The Response interceptors. + * + * @deprecated Please use `interceptors` instead. + */ + responseInterceptors?: ResponseInterceptor[] + /** + * The Network interceptors. */ - responseInterceptors: ResponseInterceptor[] + interceptors: NetworkInterceptors[] /** * The User-Agent sent with each request. * diff --git a/packages/clients/src/scw/client.ts b/packages/clients/src/scw/client.ts index 2455be74e..059ad7d15 100644 --- a/packages/clients/src/scw/client.ts +++ b/packages/clients/src/scw/client.ts @@ -1,6 +1,6 @@ import { getLogger } from '../internal/logger' import type { ClientConfig } from './client-ini-factory' -import { withProfile } from './client-ini-factory' +import { withLegacyInterceptors, withProfile } from './client-ini-factory' import type { Profile } from './client-ini-profile' import type { Settings } from './client-settings' import { assertValidSettings } from './client-settings' @@ -12,8 +12,7 @@ import type { Fetcher } from './fetch/build-fetcher' const DEFAULT_SETTINGS: Settings = { apiURL: 'https://api.scaleway.com', httpClient: fetch, - requestInterceptors: [], - responseInterceptors: [], + interceptors: [], userAgent, } @@ -50,10 +49,12 @@ export type Client = { * @public */ export const createAdvancedClient = (...configs: ClientConfig[]): Client => { - const settings = configs.reduce( - (currentSettings, config) => config(currentSettings), - DEFAULT_SETTINGS, - ) + const settings = configs + .concat([withLegacyInterceptors()]) + .reduce( + (currentSettings, config) => config(currentSettings), + DEFAULT_SETTINGS, + ) assertValidSettings(settings) getLogger().info(`init Scaleway SDK version ${version}`) diff --git a/packages/clients/src/scw/fetch/__tests__/build-fetcher.ts b/packages/clients/src/scw/fetch/__tests__/build-fetcher.ts index f7fc53ee9..e4b42c3f8 100644 --- a/packages/clients/src/scw/fetch/__tests__/build-fetcher.ts +++ b/packages/clients/src/scw/fetch/__tests__/build-fetcher.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, it, jest } from '@jest/globals' import { isBrowser } from '../../../helpers/is-browser' +import { addHeaderInterceptor } from '../../../internal/interceptors/helpers' import type { Settings } from '../../client-settings' import { buildFetcher, buildRequest } from '../build-fetcher' import type { ScwRequest } from '../types' @@ -9,6 +10,7 @@ const DEFAULT_SETTINGS: Settings = { defaultRegion: 'fr-par', defaultZone: 'fr-par-1', httpClient: global.fetch, + interceptors: [], requestInterceptors: [], responseInterceptors: [], userAgent: 'scaleway-sdk-js/v1.0.0', @@ -96,6 +98,31 @@ describe(`buildFetcher (mock)`, () => { ).resolves.toStrictEqual('dummy-output') }) + it('gets modified response', () => { + mockedFetch.mockResolvedValue( + new Response(JSON.stringify({}), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + return expect( + buildFetcher( + { + ...DEFAULT_SETTINGS, + interceptors: [ + { + response: () => new Response(JSON.stringify(42)), + }, + ], + }, + global.fetch, + )({ + method: 'POST', + path: '/undefined', + }), + ).resolves.toStrictEqual('42') + }) + it(`gets a response without error for a simple request without unmarshaller`, async () => { mockedFetch.mockResolvedValue( new Response(JSON.stringify({ any_parameter: 'any-value' }), { @@ -110,4 +137,48 @@ describe(`buildFetcher (mock)`, () => { }), ).resolves.toMatchObject({ any_parameter: 'any-value' }) }) + + it('gets a response with response error interceptor despite the error', () => { + mockedFetch.mockRejectedValue(new TypeError('')) + + return expect( + buildFetcher( + { + ...DEFAULT_SETTINGS, + interceptors: [ + { + responseError: () => Promise.resolve(42), + }, + ], + }, + global.fetch, + )({ + method: 'GET', + path: '/will-trigger-an-error', + }), + ).resolves.toBe(42) + }) + + it('gets modified request in response error', () => { + mockedFetch.mockRejectedValue(new TypeError('')) + + return expect( + buildFetcher( + { + ...DEFAULT_SETTINGS, + interceptors: [ + { + request: addHeaderInterceptor('random-header', '42'), + responseError: ({ request }) => + request.headers.get('random-header'), + }, + ], + }, + global.fetch, + )({ + method: 'GET', + path: '/will-trigger-an-error', + }), + ).resolves.toBe('42') + }) }) diff --git a/packages/clients/src/scw/fetch/__tests__/http-dumper.ts b/packages/clients/src/scw/fetch/__tests__/http-dumper.ts index c41e04498..0dd51b6a4 100644 --- a/packages/clients/src/scw/fetch/__tests__/http-dumper.ts +++ b/packages/clients/src/scw/fetch/__tests__/http-dumper.ts @@ -9,6 +9,7 @@ const DEFAULT_SETTINGS: Settings = { defaultRegion: 'fr-par', defaultZone: 'fr-par-1', httpClient: fetch, + interceptors: [], requestInterceptors: [], responseInterceptors: [], userAgent: 'scaleway-sdk-js/v1.0.0', diff --git a/packages/clients/src/scw/fetch/__tests__/http-interceptors.ts b/packages/clients/src/scw/fetch/__tests__/http-interceptors.ts index 79c6e9518..cb24914c6 100644 --- a/packages/clients/src/scw/fetch/__tests__/http-interceptors.ts +++ b/packages/clients/src/scw/fetch/__tests__/http-interceptors.ts @@ -43,13 +43,13 @@ describe(`logRequest`, () => { it(`logs nothing if the level isn't high enough`, async () => { enableDummyLogger('warn') - await logRequest('1')(request) + await logRequest('1')({ request }) expect(latestMessage).toBe('') }) it(`logs something if the level is high enough`, async () => { enableDummyLogger('debug') - await logRequest('1')(request) + await logRequest('1')({ request }) expect( latestMessage.startsWith('--------------- Scaleway SDK REQUEST 1'), ).toBe(true) @@ -61,13 +61,13 @@ describe(`logResponse`, () => { it(`logs nothing if the level isn't high enough`, async () => { enableDummyLogger('warn') - await logResponse('1')(response) + await logResponse('1')({ response }) expect(latestMessage).toBe('') }) it(`logs something if the level is high enough`, async () => { enableDummyLogger('debug') - await logResponse('1')(response) + await logResponse('1')({ response }) expect( latestMessage.startsWith('--------------- Scaleway SDK RESPONSE 1'), ).toBe(true) @@ -81,22 +81,22 @@ describe('obfuscateInterceptor', () => { [name, `${preprendValue}${value}`] it('changes the request headers', async () => { - const obfRequest = await obfuscateInterceptor(prependInterceptor('obj-'))( - new Request('https://api.scaleway.com', { + const obfRequest = await obfuscateInterceptor(prependInterceptor('obj-'))({ + request: new Request('https://api.scaleway.com', { headers: { 'X-Random-Key': 'value' }, }), - ) + }) expect(obfRequest).toBeInstanceOf(Request) expect(obfRequest.headers.get('X-Random-Key')).toBe('obj-value') }) it('clones the request without altering the headers', async () => { - const obfRequest = await obfuscateInterceptor(prependInterceptor('obj-'))( - new Request('https://api.scaleway.com', { + const obfRequest = await obfuscateInterceptor(prependInterceptor('obj-'))({ + request: new Request('https://api.scaleway.com', { headers: { 'X-Random-Key': 'value' }, }), - ) + }) expect(obfRequest.clone().headers.get('X-Random-Key')).toBe('obj-value') }) diff --git a/packages/clients/src/scw/fetch/build-fetcher.ts b/packages/clients/src/scw/fetch/build-fetcher.ts index 6fe1d0d65..e2db67698 100644 --- a/packages/clients/src/scw/fetch/build-fetcher.ts +++ b/packages/clients/src/scw/fetch/build-fetcher.ts @@ -1,5 +1,14 @@ import { isBrowser } from '../../helpers/is-browser' -import { composeInterceptors } from '../../internal/interceptors/interceptor' +import type { + RequestInterceptor, + ResponseErrorInterceptor, + ResponseInterceptor, +} from '../../index' +import { + composeRequestInterceptors, + composeResponseErrorInterceptors, + composeResponseInterceptors, +} from '../../internal/interceptors/composer' import { obfuscateAuthHeadersEntry } from '../auth' import type { Settings } from '../client-settings' import { @@ -60,26 +69,50 @@ export type Fetcher = ( export const buildFetcher = (settings: Settings, httpClient: typeof fetch) => { let requestNumber = 0 const prepareRequest = (requestId: string) => - composeInterceptors([ - ...settings.requestInterceptors, + composeRequestInterceptors([ + ...(settings.interceptors + .map(obj => obj.request) + .filter(obj => obj) as RequestInterceptor[]), logRequest(requestId, obfuscateInterceptor(obfuscateAuthHeadersEntry)), ]) const prepareResponse = (requestId: string) => - composeInterceptors([ - ...settings.responseInterceptors, + composeResponseInterceptors([ + ...(settings.interceptors + .map(obj => obj.response) + .filter(obj => obj) as ResponseInterceptor[]), logResponse(requestId), ]) + const prepareResponseErrors = () => + composeResponseErrorInterceptors( + settings.interceptors + .map(obj => obj.responseError) + .filter(obj => obj) as ResponseErrorInterceptor[], + ) return async ( request: Readonly, unwrapper: ResponseUnmarshaller = asIs, ): Promise => { const requestId = `${(requestNumber += 1)}` + const reqInterceptors = prepareRequest(requestId) + const finalRequest = await reqInterceptors(buildRequest(request, settings)) + + try { + const response = await httpClient(finalRequest) + const resInterceptors = prepareResponse(requestId) + const finalResponse = await resInterceptors(response) + const resUnmarshaller = responseParser( + unwrapper, + request.responseType ?? 'json', + ) + const unmarshaledResponse = await resUnmarshaller(finalResponse) + + return unmarshaledResponse + } catch (err) { + const resErrorInterceptors = prepareResponseErrors() + const handledError = (await resErrorInterceptors(finalRequest, err)) as T - return Promise.resolve(buildRequest(request, settings)) - .then(prepareRequest(requestId)) - .then(httpClient) - .then(prepareResponse(requestId)) - .then(responseParser(unwrapper, request.responseType ?? 'json')) + return handledError + } } } diff --git a/packages/clients/src/scw/fetch/http-interceptors.ts b/packages/clients/src/scw/fetch/http-interceptors.ts index c9d914391..b1a03abc4 100644 --- a/packages/clients/src/scw/fetch/http-interceptors.ts +++ b/packages/clients/src/scw/fetch/http-interceptors.ts @@ -1,5 +1,7 @@ -import type { RequestInterceptor } from '../../internal/interceptors/request' -import type { ResponseInterceptor } from '../../internal/interceptors/response' +import type { + RequestInterceptor, + ResponseInterceptor, +} from '../../internal/interceptors/types' import { getLogger } from '../../internal/logger' import { LevelResolver, shouldLog } from '../../internal/logger/level-resolver' import { dumpRequest, dumpResponse } from './http-dumper' @@ -42,10 +44,11 @@ class ObfuscatedRequest extends Request { */ export const obfuscateInterceptor = (obfuscate: HeaderEntryMapper): RequestInterceptor => - request => + ({ request }) => new ObfuscatedRequest(request, obfuscate) -const identity = (instance: T) => instance +const identity: RequestInterceptor = ({ request }: { request: Request }) => + request /** * Creates an interceptor to log the requests. @@ -61,11 +64,11 @@ export const logRequest = identifier: string, obfuscate: RequestInterceptor = identity, ): RequestInterceptor => - async request => { + async ({ request }) => { if (shouldLog(LevelResolver[getLogger().logLevel], 'debug')) { getLogger().debug( `--------------- Scaleway SDK REQUEST ${identifier} --------------- -${await dumpRequest(await obfuscate(request))} +${await dumpRequest(await obfuscate({ request }))} ---------------------------------------------------------`, ) } @@ -83,7 +86,7 @@ ${await dumpRequest(await obfuscate(request))} */ export const logResponse = (identifier: string): ResponseInterceptor => - async response => { + async ({ response }) => { if (shouldLog(LevelResolver[getLogger().logLevel], 'debug')) { getLogger().debug( `--------------- Scaleway SDK RESPONSE ${identifier} ---------------