Skip to content

Commit cd58ba4

Browse files
authored
feat: add responseError interceptor (#643)
1 parent 77ad561 commit cd58ba4

23 files changed

+594
-134
lines changed

packages/clients/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ export type {
22
WaitForOptions,
33
WaitForStopCondition,
44
} from './internal/async/interval-retrier'
5+
export type {
6+
NetworkInterceptors,
7+
RequestInterceptor,
8+
ResponseInterceptor,
9+
ResponseErrorInterceptor,
10+
} from './internal/interceptors/types'
511
export { enableConsoleLogger, setLogger } from './internal/logger'
612
export type { Logger } from './internal/logger/logger'
713
export { createClient, createAdvancedClient } from './scw/client'
814
export type { Client } from './scw/client'
915
export type { Profile } from './scw/client-ini-profile'
1016
export type { Settings } from './scw/client-settings'
1117
export {
18+
withAdditionalInterceptors,
1219
withDefaultPageSize,
1320
withHTTPClient,
1421
withProfile,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from '@jest/globals'
2+
import {
3+
composeRequestInterceptors,
4+
composeResponseErrorInterceptors,
5+
} from '../composer'
6+
7+
describe('composeRequestInterceptors', () => {
8+
it('modifies the request header', async () => {
9+
const interceptor = composeRequestInterceptors([
10+
({ request }): Request => {
11+
const clone = request.clone()
12+
clone.headers.set('new-header', '42')
13+
14+
return clone
15+
},
16+
])
17+
18+
return expect(
19+
interceptor(new Request('https://api.scaleway.com')).then(obj =>
20+
obj.headers.get('new-header'),
21+
),
22+
).resolves.toBe('42')
23+
})
24+
})
25+
26+
describe('composeResponseErrorInterceptors', () => {
27+
it('passes the error to all interceptors if they all throw', () => {
28+
class NumberError extends Error {
29+
counter: number
30+
31+
constructor(obj: number) {
32+
super()
33+
this.counter = obj
34+
Object.setPrototypeOf(this, NumberError.prototype)
35+
}
36+
}
37+
38+
const interceptors = composeResponseErrorInterceptors([
39+
({ error }): Promise<unknown> => {
40+
throw error instanceof NumberError
41+
? new NumberError(error.counter + 1)
42+
: error
43+
},
44+
({ error }): Promise<unknown> => {
45+
throw error instanceof NumberError
46+
? new NumberError(error.counter + 2)
47+
: error
48+
},
49+
])(new Request('https://api.scaleway.com'), new NumberError(42))
50+
51+
return expect(interceptors).rejects.toThrow(new NumberError(45))
52+
})
53+
54+
it('stops at the second interceptor (amongst three) if it resolves', () => {
55+
const interceptors = composeResponseErrorInterceptors([
56+
({ error }): Promise<unknown> => {
57+
throw error
58+
},
59+
(): Promise<unknown> => Promise.resolve(42),
60+
({ error }): Promise<unknown> => {
61+
throw error
62+
},
63+
])(new Request('https://api.scaleway.com'), new TypeError(''))
64+
65+
return expect(interceptors).resolves.toBe(42)
66+
})
67+
68+
it('throws the last processed error', () => {
69+
const interceptors = composeResponseErrorInterceptors([
70+
({ error }): Promise<unknown> => {
71+
throw error
72+
},
73+
(): Promise<unknown> => {
74+
throw new TypeError('second error')
75+
},
76+
])(new Request('https://api.scaleway.com'), new TypeError('first error'))
77+
78+
return expect(interceptors).rejects.toThrow(new TypeError('second error'))
79+
})
80+
})
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
11
import { describe, expect, it } from '@jest/globals'
2-
import { addAsyncHeaderInterceptor, addHeaderInterceptor } from '../request'
2+
import { addAsyncHeaderInterceptor, addHeaderInterceptor } from '../helpers'
33

44
describe('addHeaderInterceptor', () => {
55
it('insertsnothing if value is undefined', async () => {
6-
const req = new Request('https://api.scaleway.com/my/path')
7-
const updatedReq = await addHeaderInterceptor('my-key', undefined)(req)
6+
const request = new Request('https://api.scaleway.com/my/path')
7+
const updatedReq = await addHeaderInterceptor(
8+
'my-key',
9+
undefined,
10+
)({ request })
811
expect(updatedReq.headers.has('my-key')).toBe(false)
912
})
1013

1114
it('inserts 1 key/value in the request', async () => {
12-
const req = new Request('https://api.scaleway.com/my/path')
13-
const updatedReq = await addHeaderInterceptor('my-key', 'my-value')(req)
15+
const request = new Request('https://api.scaleway.com/my/path')
16+
const updatedReq = await addHeaderInterceptor(
17+
'my-key',
18+
'my-value',
19+
)({ request })
1420
expect(updatedReq.headers.get('my-key')).toBe('my-value')
1521
})
1622

1723
it(`desn't modify the input request`, async () => {
18-
const req = new Request('https://api.scaleway.com/my/path')
19-
const updatedReq = await addHeaderInterceptor('my-key', 'my-value')(req)
20-
expect(req).not.toStrictEqual(updatedReq)
24+
const request = new Request('https://api.scaleway.com/my/path')
25+
const updatedReq = await addHeaderInterceptor(
26+
'my-key',
27+
'my-value',
28+
)({ request })
29+
expect(request).not.toStrictEqual(updatedReq)
2130
})
2231
})
2332

2433
describe('addAsyncHeaderInterceptor', () => {
2534
it('inserts 1 key/value in the request', async () => {
26-
const req = new Request('https://api.scaleway.com/my/path')
35+
const request = new Request('https://api.scaleway.com/my/path')
2736
const updatedReq = await addAsyncHeaderInterceptor('my-key', async () =>
2837
Promise.resolve('my-value'),
29-
)(req)
38+
)({ request })
3039
expect(updatedReq.headers.get('my-key')).toBe('my-value')
3140
})
3241
})

packages/clients/src/internal/interceptors/__tests__/interceptor.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type {
2+
RequestInterceptor,
3+
ResponseErrorInterceptor,
4+
ResponseInterceptor,
5+
} from './types'
6+
7+
/**
8+
* Composes request interceptors.
9+
*
10+
* @param interceptors - A list of request interceptors
11+
* @returns An async composed interceptor
12+
*
13+
* @internal
14+
*/
15+
export const composeRequestInterceptors =
16+
(interceptors: RequestInterceptor[]) =>
17+
async (request: Request): Promise<Request> =>
18+
interceptors.reduce(
19+
async (asyncResult, interceptor): Promise<Request> =>
20+
interceptor({ request: await asyncResult }),
21+
Promise.resolve(request),
22+
)
23+
24+
/**
25+
* Composes response interceptors.
26+
*
27+
* @param interceptors - A list of response interceptors
28+
* @returns An async composed interceptor
29+
*
30+
* @internal
31+
*/
32+
export const composeResponseInterceptors =
33+
(interceptors: ResponseInterceptor[]) =>
34+
async (response: Response): Promise<Response> =>
35+
interceptors.reduce(
36+
async (asyncResult, interceptor): Promise<Response> =>
37+
interceptor({ response: await asyncResult }),
38+
Promise.resolve(response),
39+
)
40+
41+
/**
42+
* Compose response error interceptors.
43+
*
44+
* @internal
45+
*/
46+
export const composeResponseErrorInterceptors =
47+
(interceptors: ResponseErrorInterceptor[]) =>
48+
async (request: Request, error: unknown): Promise<unknown> => {
49+
let prevError = error
50+
for (const interceptor of interceptors) {
51+
try {
52+
const res = await interceptor({ request, error: prevError })
53+
54+
return res
55+
} catch (err) {
56+
prevError = err
57+
}
58+
}
59+
60+
throw prevError
61+
}

packages/clients/src/internal/interceptors/request.ts renamed to packages/clients/src/internal/interceptors/helpers.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import type { Interceptor } from './interceptor'
2-
3-
/** Request Interceptor. */
4-
export type RequestInterceptor = Interceptor<Request>
1+
import type { RequestInterceptor } from './types'
52

63
/**
74
* Adds an header to a request through an interceptor.
@@ -14,7 +11,7 @@ export type RequestInterceptor = Interceptor<Request>
1411
*/
1512
export const addHeaderInterceptor =
1613
(key: string, value?: string): RequestInterceptor =>
17-
request => {
14+
({ request }) => {
1815
const clone = request.clone()
1916
if (value !== undefined) {
2017
clone.headers.append(key, value)

packages/clients/src/internal/interceptors/interceptor.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

packages/clients/src/internal/interceptors/response.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Defines the interceptor for a `Request`.
3+
* This allows you to intercept requests before starting.
4+
*
5+
* @example
6+
* Adds a custom header to a request:
7+
* ```
8+
* const addCustomHeaderInterceptor
9+
* ({ key, value }: { key: string; value: string }): RequestInterceptor =>
10+
* ({ request }) => {
11+
* const clone = request.clone()
12+
* clone.headers.set(key, value)
13+
*
14+
* return clone
15+
* }
16+
* ```
17+
*
18+
* @public
19+
*/
20+
export interface RequestInterceptor {
21+
({ request }: { request: Readonly<Request> }): Request | Promise<Request>
22+
}
23+
24+
/**
25+
* Defines the interceptor for a `Response`.
26+
* This allows you to intercept responses before unmarshalling.
27+
*
28+
* @example
29+
* Adds a delay before sending the response:
30+
* ```
31+
* const addDelayInterceptor: ResponseInterceptor = ({ response }) =>
32+
* new Promise(resolve => {
33+
* setTimeout(() => resolve(response), 1000)
34+
* })
35+
* ```
36+
*
37+
* @public
38+
*/
39+
export interface ResponseInterceptor {
40+
({ response }: { response: Readonly<Response> }): Response | Promise<Response>
41+
}
42+
43+
/**
44+
* Defines the interceptor for a `Response` error.
45+
* This allows you to intercept a response error before exiting the whole process.
46+
* You can either rethrow an error, and resolve with a different response.
47+
*
48+
* @remarks
49+
* You must return either:
50+
* 1. An error (`throw error` or `Promise.reject(error)`)
51+
* 2. Data (directly, or via a Promise)
52+
*
53+
* @example
54+
* Reports error to tracking service:
55+
* ```
56+
* const reportErrorToTrackingService: ResponseErrorInterceptor = async ({
57+
* request,
58+
* error,
59+
* }: {
60+
* request: Request
61+
* error: unknown
62+
* }) => {
63+
* await sendErrorToErrorService(request, error)
64+
* throw error
65+
* }
66+
* ```
67+
*
68+
* @public
69+
*/
70+
export interface ResponseErrorInterceptor {
71+
({ request, error }: { request: Request; error: unknown }):
72+
| unknown
73+
| Promise<unknown>
74+
}
75+
76+
/**
77+
* Defines the network interceptors.
78+
* Please check the documentation of {@link RequestInterceptor},
79+
* {@link ResponseInterceptor} and {@link ResponseErrorInterceptor} for examples.
80+
*
81+
* @public
82+
*/
83+
export type NetworkInterceptors = {
84+
request?: RequestInterceptor
85+
response?: ResponseInterceptor
86+
responseError?: ResponseErrorInterceptor
87+
}

packages/clients/src/internals.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
export { isJSONObject } from './helpers/json'
22
export { waitForResource } from './internal/async/interval-retrier'
3-
export type { RequestInterceptor } from './internal/interceptors/request'
4-
export type { ResponseInterceptor } from './internal/interceptors/response'
3+
export { addAsyncHeaderInterceptor } from './internal/interceptors/helpers'
54
export { API } from './scw/api'
5+
/* eslint-disable deprecation/deprecation */
66
export { authenticateWithSessionToken } from './scw/auth'
7+
/* eslint-enable deprecation/deprecation */
78
export type { DefaultValues } from './scw/client-settings'
89
export {
910
marshalScwFile,

0 commit comments

Comments
 (0)