@datocms/rest-client-utils 5.8.0 → 6.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/__tests__/request.test.js +85 -0
- package/dist/cjs/__tests__/request.test.js.map +1 -0
- package/dist/cjs/errors.js +14 -0
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/request.js +35 -7
- package/dist/cjs/request.js.map +1 -1
- package/dist/esm/__tests__/buildNormalizedParams.test.js +2 -11
- package/dist/esm/__tests__/buildNormalizedParams.test.js.map +1 -1
- package/dist/esm/__tests__/request.test.d.ts +1 -0
- package/dist/esm/__tests__/request.test.js +78 -0
- package/dist/esm/__tests__/request.test.js.map +1 -0
- package/dist/esm/deserialize.js +23 -8
- package/dist/esm/deserialize.js.map +1 -1
- package/dist/esm/errors.js +14 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/pollJobResult.js +15 -26
- package/dist/esm/pollJobResult.js.map +1 -1
- package/dist/esm/rawPageIterator.js +26 -40
- package/dist/esm/rawPageIterator.js.map +1 -1
- package/dist/esm/request.js +140 -117
- package/dist/esm/request.js.map +1 -1
- package/dist/esm/serialize.js +11 -16
- package/dist/esm/serialize.js.map +1 -1
- package/dist/types/__tests__/request.test.d.ts +1 -0
- package/package.json +2 -2
- package/src/__tests__/request.test.ts +105 -0
- package/src/errors.ts +17 -0
- package/src/request.ts +44 -8
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ApiError, TimeoutError } from '../errors';
|
|
2
|
+
import { request } from '../request';
|
|
3
|
+
|
|
4
|
+
const API_TOKEN = 'aaaabbbbccccdddd';
|
|
5
|
+
|
|
6
|
+
function jsonResponse(status: number, body: unknown) {
|
|
7
|
+
return new Response(JSON.stringify(body), {
|
|
8
|
+
status,
|
|
9
|
+
statusText: 'Unprocessable Entity',
|
|
10
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildOptions(fetchFn: typeof fetch) {
|
|
15
|
+
return {
|
|
16
|
+
baseUrl: 'https://site-api.datocms.com',
|
|
17
|
+
fetchJobResult: async () => {
|
|
18
|
+
throw new Error('not needed');
|
|
19
|
+
},
|
|
20
|
+
fetchFn,
|
|
21
|
+
apiToken: API_TOKEN,
|
|
22
|
+
method: 'GET' as const,
|
|
23
|
+
url: '/items/bad-id',
|
|
24
|
+
autoRetry: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('request()', () => {
|
|
29
|
+
it('sends the real API token, but keeps it out of the error', async () => {
|
|
30
|
+
let sentAuthorization: string | undefined;
|
|
31
|
+
|
|
32
|
+
const fetchFn = jest.fn(async (_url: unknown, init?: RequestInit) => {
|
|
33
|
+
sentAuthorization = (init?.headers as Record<string, string>)
|
|
34
|
+
?.authorization;
|
|
35
|
+
return jsonResponse(422, { data: [] });
|
|
36
|
+
}) as unknown as typeof fetch;
|
|
37
|
+
|
|
38
|
+
const error: ApiError = await request(buildOptions(fetchFn)).then(
|
|
39
|
+
() => {
|
|
40
|
+
throw new Error('expected the request to fail');
|
|
41
|
+
},
|
|
42
|
+
(error) => error,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(error).toBeInstanceOf(ApiError);
|
|
46
|
+
expect(sentAuthorization).toBe(`Bearer ${API_TOKEN}`);
|
|
47
|
+
expect(error.request.headers.authorization).toBe(
|
|
48
|
+
'[REDACTED, ending in dddd]',
|
|
49
|
+
);
|
|
50
|
+
expect(JSON.stringify(error)).not.toContain(API_TOKEN);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the token out of a timeout error too', async () => {
|
|
54
|
+
const fetchFn = (async () => {
|
|
55
|
+
const error: NodeJS.ErrnoException = new Error('timeout');
|
|
56
|
+
error.code = 'ETIMEDOUT';
|
|
57
|
+
throw error;
|
|
58
|
+
}) as unknown as typeof fetch;
|
|
59
|
+
|
|
60
|
+
const error: TimeoutError = await request({
|
|
61
|
+
...buildOptions(fetchFn),
|
|
62
|
+
autoRetry: false,
|
|
63
|
+
}).then(
|
|
64
|
+
() => {
|
|
65
|
+
throw new Error('expected the request to fail');
|
|
66
|
+
},
|
|
67
|
+
(error) => error,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(error).toBeInstanceOf(TimeoutError);
|
|
71
|
+
expect(error.request.headers.authorization).toBe(
|
|
72
|
+
'[REDACTED, ending in dddd]',
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not spill the failed call through incidental serialization', async () => {
|
|
77
|
+
const fetchFn = (async () =>
|
|
78
|
+
jsonResponse(422, {
|
|
79
|
+
data: [],
|
|
80
|
+
})) as unknown as typeof fetch;
|
|
81
|
+
|
|
82
|
+
const error: ApiError = await request({
|
|
83
|
+
...buildOptions(fetchFn),
|
|
84
|
+
body: {
|
|
85
|
+
data: { attributes: { url: 'https://example.com/?token=s3cr3t' } },
|
|
86
|
+
},
|
|
87
|
+
}).then(
|
|
88
|
+
() => {
|
|
89
|
+
throw new Error('expected the request to fail');
|
|
90
|
+
},
|
|
91
|
+
(error) => error,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// `request` and `response` stay readable...
|
|
95
|
+
expect(error.request.url).toContain('/items/bad-id');
|
|
96
|
+
expect(error.response.status).toBe(422);
|
|
97
|
+
|
|
98
|
+
// ...but no longer travel by accident: this is what `console.error()`,
|
|
99
|
+
// `serialize-error` and most error trackers walk.
|
|
100
|
+
expect(Object.keys(error)).not.toContain('request');
|
|
101
|
+
expect(Object.keys(error)).not.toContain('response');
|
|
102
|
+
expect(JSON.stringify(error)).not.toContain('s3cr3t');
|
|
103
|
+
expect(JSON.stringify({ ...error })).not.toContain('s3cr3t');
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -72,6 +72,19 @@ export type ApiErrorResponse = {
|
|
|
72
72
|
const TIMEOUT_ERROR = Symbol.for('@datocms/rest-client-utils:TimeoutError');
|
|
73
73
|
const API_ERROR = Symbol.for('@datocms/rest-client-utils:ApiError');
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Hides properties from anything that walks own enumerable keys —
|
|
77
|
+
* `console.error()`, `JSON.stringify()`, object spread, `serialize-error`,
|
|
78
|
+
* error trackers. Reading `error.request` explicitly keeps working exactly as
|
|
79
|
+
* before; the data just stops travelling by accident, since the request and
|
|
80
|
+
* response of a failed call can carry credentials and other secrets.
|
|
81
|
+
*/
|
|
82
|
+
function hideProperties(target: object, keys: string[]) {
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
Object.defineProperty(target, key, { enumerable: false });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
export type TimeoutErrorInitObject = {
|
|
76
89
|
request: ApiErrorRequest;
|
|
77
90
|
preCallStack?: string;
|
|
@@ -126,6 +139,8 @@ export class TimeoutError extends Error {
|
|
|
126
139
|
this.request = initObject.request;
|
|
127
140
|
this.preCallStack = initObject.preCallStack;
|
|
128
141
|
|
|
142
|
+
hideProperties(this, ['request', 'preCallStack']);
|
|
143
|
+
|
|
129
144
|
this.message = `${initObject.request.method} ${initObject.request.url}: Timeout error`;
|
|
130
145
|
|
|
131
146
|
if (this.preCallStack) {
|
|
@@ -187,6 +202,8 @@ export class ApiError extends Error {
|
|
|
187
202
|
this.response = initObject.response;
|
|
188
203
|
this.preCallStack = initObject.preCallStack;
|
|
189
204
|
|
|
205
|
+
hideProperties(this, ['request', 'response', 'preCallStack']);
|
|
206
|
+
|
|
190
207
|
let message = `${initObject.request.method} ${initObject.request.url}: ${this.response.status} ${this.response.statusText}`;
|
|
191
208
|
|
|
192
209
|
if (this.errors.length > 0) {
|
package/src/request.ts
CHANGED
|
@@ -68,6 +68,36 @@ function headersToObject(headers: Headers): Record<string, string> {
|
|
|
68
68
|
return result;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Headers whose value must never end up inside an error object.
|
|
73
|
+
*
|
|
74
|
+
* Right now this client only ever sets one of them, but the list is the point:
|
|
75
|
+
* a header added here is redacted everywhere an error can reach.
|
|
76
|
+
*/
|
|
77
|
+
const SENSITIVE_HEADERS = ['authorization'];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns a copy of the request headers with every sensitive value blanked out.
|
|
81
|
+
*
|
|
82
|
+
* Errors travel: they get logged, serialized, sent to error trackers, and — as
|
|
83
|
+
* we learned the hard way — occasionally echoed back to an HTTP client. The
|
|
84
|
+
* real headers are only ever handed to `fetch()`; what we keep on the error is
|
|
85
|
+
* this redacted copy.
|
|
86
|
+
*/
|
|
87
|
+
function redactHeaders(
|
|
88
|
+
headers: Record<string, string>,
|
|
89
|
+
): Record<string, string> {
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(headers).map(([key, value]) =>
|
|
92
|
+
SENSITIVE_HEADERS.includes(key.toLowerCase())
|
|
93
|
+
? // The last 4 characters are enough to tell two tokens apart while
|
|
94
|
+
// debugging, and useless to whoever gets hold of the log.
|
|
95
|
+
[key, `[REDACTED, ending in ${value.slice(-4)}]`]
|
|
96
|
+
: [key, value],
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
function buildApiErrorInitObject(
|
|
72
102
|
method: string,
|
|
73
103
|
url: string,
|
|
@@ -191,6 +221,8 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
191
221
|
delete headers['user-agent'];
|
|
192
222
|
}
|
|
193
223
|
|
|
224
|
+
const redactedHeaders = redactHeaders(headers);
|
|
225
|
+
|
|
194
226
|
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
195
227
|
const body = options.body ? JSON.stringify(options.body, null, 2) : undefined;
|
|
196
228
|
|
|
@@ -206,7 +238,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
206
238
|
if (logLevel >= LogLevel.BASIC) {
|
|
207
239
|
log(`[${requestId}] ${options.method} ${url}`);
|
|
208
240
|
if (logLevel >= LogLevel.BODY_AND_HEADERS) {
|
|
209
|
-
for (const [key, value] of Object.entries(
|
|
241
|
+
for (const [key, value] of Object.entries(redactedHeaders)) {
|
|
210
242
|
log(`[${requestId}] ${key}: ${value}`);
|
|
211
243
|
}
|
|
212
244
|
}
|
|
@@ -215,6 +247,10 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
215
247
|
}
|
|
216
248
|
}
|
|
217
249
|
|
|
250
|
+
// Declared out here so that the `finally` below can always clear it: a timer
|
|
251
|
+
// left pending keeps Node's event loop alive for as long as it runs.
|
|
252
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
253
|
+
|
|
218
254
|
try {
|
|
219
255
|
const requestPromise = makeCancelablePromise(
|
|
220
256
|
fetchFn(url, {
|
|
@@ -224,14 +260,12 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
224
260
|
}),
|
|
225
261
|
);
|
|
226
262
|
|
|
227
|
-
|
|
263
|
+
timeoutId = setTimeout(() => {
|
|
228
264
|
requestPromise.cancel();
|
|
229
265
|
}, options.requestTimeout || 30000);
|
|
230
266
|
|
|
231
267
|
const response = await requestPromise;
|
|
232
268
|
|
|
233
|
-
clearTimeout(timeoutId);
|
|
234
|
-
|
|
235
269
|
const responseContentType = response.headers.get('Content-Type');
|
|
236
270
|
const invalidContentType =
|
|
237
271
|
responseContentType && !responseContentType.includes('application/json');
|
|
@@ -242,7 +276,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
242
276
|
buildApiErrorInitObject(
|
|
243
277
|
options.method,
|
|
244
278
|
url,
|
|
245
|
-
|
|
279
|
+
redactedHeaders,
|
|
246
280
|
options.body,
|
|
247
281
|
response,
|
|
248
282
|
undefined,
|
|
@@ -308,7 +342,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
308
342
|
buildApiErrorInitObjectFromJobResult(
|
|
309
343
|
options.method,
|
|
310
344
|
url,
|
|
311
|
-
|
|
345
|
+
redactedHeaders,
|
|
312
346
|
options.body,
|
|
313
347
|
jobResult.status,
|
|
314
348
|
jobResult.payload,
|
|
@@ -328,7 +362,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
328
362
|
buildApiErrorInitObject(
|
|
329
363
|
options.method,
|
|
330
364
|
url,
|
|
331
|
-
|
|
365
|
+
redactedHeaders,
|
|
332
366
|
options.body,
|
|
333
367
|
response,
|
|
334
368
|
responseBody,
|
|
@@ -373,7 +407,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
373
407
|
buildTimeoutErrorInitObject(
|
|
374
408
|
options.method,
|
|
375
409
|
url,
|
|
376
|
-
|
|
410
|
+
redactedHeaders,
|
|
377
411
|
options.body,
|
|
378
412
|
preCallStack,
|
|
379
413
|
),
|
|
@@ -381,5 +415,7 @@ export async function request<T>(options: RequestOptions): Promise<T> {
|
|
|
381
415
|
}
|
|
382
416
|
|
|
383
417
|
throw error;
|
|
418
|
+
} finally {
|
|
419
|
+
clearTimeout(timeoutId);
|
|
384
420
|
}
|
|
385
421
|
}
|