• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

box / box-typescript-sdk-gen / 11817965999

13 Nov 2024 01:10PM UTC coverage: 41.715% (+0.03%) from 41.685%
11817965999

Pull #411

github

web-flow
Merge 0d287c38e into e48500756
Pull Request #411: chore: add codegen diff job (box/box-codegen#598)

4058 of 16837 branches covered (24.1%)

Branch coverage included in aggregate %.

2 of 7 new or added lines in 1 file covered. (28.57%)

6 existing lines in 1 file now uncovered.

13372 of 24947 relevant lines covered (53.6%)

76.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.7
/src/networking/fetch.ts
1
import nodeFetch, { RequestInit } from 'node-fetch';
140✔
2
import type { Readable } from 'stream';
3

4
import { BoxApiError, BoxSdkError } from '../box/errors';
140✔
5
import {
140✔
6
  ByteStream,
7
  CancellationToken,
8
  generateByteStreamFromBuffer,
9
  isBrowser,
10
  readByteStream,
11
} from '../internal/utils';
12
import { sdkVersion } from './version';
140✔
13
import {
140✔
14
  SerializedData,
15
  jsonToSerializedData,
16
  sdIsMap,
17
  sdToJson,
18
  sdToUrlParams,
19
} from '../serialization/json';
20
import { Authentication } from './auth.generated';
21
import { Interceptor } from './interceptors.generated';
22
import { NetworkSession } from './network.generated';
23

24
export const userAgentHeader = `Box JavaScript generated SDK v${sdkVersion} (${
140✔
25
  isBrowser() ? navigator.userAgent : `Node ${process.version}`
140!
26
})`;
27
export const xBoxUaHeader = constructBoxUAHeader();
140✔
28

29
export interface MultipartItem {
30
  readonly partName: string;
31
  readonly data?: SerializedData;
32
  readonly fileStream?: ByteStream;
33
  readonly fileName?: string;
34
  readonly contentType?: string;
35
}
36

37
export interface FetchOptions {
38
  /**
39
   * A string to set request's URL.
40
   */
41
  readonly url: string;
42
  /**
43
   * A string to set request's method (GET, POST, etc.). Defaults to GET.
44
   */
45
  readonly method?: string;
46
  /**
47
   * [key1, value1, key2, value2, ...]
48
   */
49
  readonly headers?: {
50
    [key: string]: string;
51
  };
52
  /**
53
   * query params
54
   * [key1, value1, key2, value2, ...]
55
   */
56
  readonly params?: {
57
    [key: string]: string;
58
  };
59

60
  /**
61
   * Request body data
62
   */
63
  readonly data?: SerializedData;
64

65
  /**
66
   * Stream of a file
67
   */
68
  readonly fileStream?: ByteStream;
69

70
  /**
71
   * Parts of multipart data
72
   */
73
  readonly multipartData?: MultipartItem[];
74

75
  /**
76
   * Request body content type
77
   */
78
  readonly contentType?: string;
79

80
  /**
81
   * Expected format of the response: 'json', 'binary' or undefined
82
   */
83
  readonly responseFormat?: string;
84

85
  /**
86
   * Auth object
87
   */
88
  readonly auth?: Authentication;
89
  /**
90
   *
91
   */
92
  readonly networkSession?: NetworkSession;
93

94
  /**
95
   * Token used for request cancellation
96
   */
97
  readonly cancellationToken?: CancellationToken;
98
}
99

100
export interface FetchResponse {
101
  /**
102
   * The status code of the response. (This will be 200 for a success).
103
   */
104
  readonly status: number;
105

106
  /**
107
   * Response body data
108
   */
109
  readonly data: SerializedData;
110

111
  /**
112
   * Binary array buffer of response body
113
   */
114
  readonly content: ByteStream;
115

116
  readonly headers: {
117
    [key: string]: string;
118
  };
119
}
120

121
async function createRequestInit(options: FetchOptions): Promise<RequestInit> {
122
  const {
123
    method = 'GET',
×
124
    headers = {},
×
125
    contentType: contentTypeInput = 'application/json',
746✔
126
    data,
127
    fileStream,
128
  } = options;
1,472✔
129

130
  const { contentType, body } = await (async (): Promise<{
1,472✔
131
    contentType: string | undefined;
132
    body: Readable | string;
133
  }> => {
1,472✔
134
    if (options.multipartData) {
1,472✔
135
      const FormData = isBrowser()
98!
136
        ? window.FormData
137
        : eval('require')('form-data');
138
      const formData = new FormData();
98✔
139
      for (const item of options.multipartData) {
98✔
140
        if (item.fileStream) {
194✔
141
          const buffer = await readByteStream(item.fileStream);
98✔
142
          const blob = isBrowser() ? new Blob([buffer]) : buffer;
98!
143
          headers['content-md5'] = await calculateMD5Hash(buffer);
98✔
144
          formData.append(item.partName, blob, {
98✔
145
            filename: item.fileName ?? 'file',
294✔
146
            contentType: item.contentType ?? 'application/octet-stream',
294✔
147
          });
148
        } else if (item.data) {
96!
149
          formData.append(item.partName, sdToJson(item.data));
96✔
150
        } else {
151
          throw new BoxSdkError({
×
152
            message: 'Multipart item must have either body or fileStream',
153
          });
154
        }
155
      }
156

157
      return {
98✔
158
        contentType: !isBrowser()
98!
159
          ? `multipart/form-data; boundary=${formData.getBoundary()}`
160
          : undefined,
161
        body: formData,
162
      };
163
    }
164

165
    const contentType = contentTypeInput;
1,374✔
166
    switch (contentType) {
1,374!
167
      case 'application/json':
168
      case 'application/json-patch+json':
169
        return { contentType, body: sdToJson(data) };
1,084✔
170

171
      case 'application/x-www-form-urlencoded':
172
        return { contentType, body: sdToUrlParams(data) };
278✔
173

174
      case 'application/octet-stream':
175
        if (!fileStream) {
12!
176
          throw new BoxSdkError({
×
177
            message:
178
              'fileStream required for application/octet-stream content type',
179
          });
180
        }
181
        return { contentType, body: fileStream };
12✔
182

183
      default:
184
        throw new BoxSdkError({
×
185
          message: `Unsupported content type : ${contentType}`,
186
        });
187
    }
188
  })();
189

190
  return {
1,472✔
191
    method,
192
    headers: {
193
      ...(contentType && { 'Content-Type': contentType }),
2,944✔
194
      ...headers,
195
      ...(options.auth && {
2,666✔
196
        Authorization: await options.auth.retrieveAuthorizationHeader(
197
          options.networkSession,
198
        ),
199
      }),
200
      'User-Agent': userAgentHeader,
201
      'X-Box-UA': xBoxUaHeader,
202
      // Additional headers will override the default headers
203
      ...options.networkSession?.additionalHeaders,
4,416!
204
    },
205
    body,
206
    signal: options.cancellationToken as RequestInit['signal'],
207
    agent: options.networkSession?.agent,
4,416!
208
  };
209
}
210

211
const DEFAULT_MAX_ATTEMPTS = 5;
140✔
212
const RETRY_BASE_INTERVAL = 1;
140✔
213
const STATUS_CODE_ACCEPTED = 202,
140✔
214
  STATUS_CODE_UNAUTHORIZED = 401,
140✔
215
  STATUS_CODE_TOO_MANY_REQUESTS = 429;
140✔
216

217
export async function fetch(
140✔
218
  options: FetchOptions & {
219
    /** @private */
220
    numRetries?: number;
221
  },
222
): Promise<FetchResponse> {
223
  const fetchOptions: typeof options = options.networkSession?.interceptors
1,472!
224
    ?.length
225
    ? options.networkSession?.interceptors.reduce(
30!
226
        (modifiedOptions: FetchOptions, interceptor: Interceptor) =>
227
          interceptor.beforeRequest(modifiedOptions),
12✔
228
        options,
229
      )
230
    : options;
231
  const fileStreamBuffer = fetchOptions.fileStream
1,472✔
232
    ? await readByteStream(fetchOptions.fileStream)
233
    : void 0;
234
  const requestInit = await createRequestInit({
1,472✔
235
    ...fetchOptions,
236
    fileStream: fileStreamBuffer
1,472✔
237
      ? generateByteStreamFromBuffer(fileStreamBuffer)
238
      : void 0,
239
  });
240

241
  const { params = {} } = fetchOptions;
1,472✔
242
  const response = await nodeFetch(
1,472✔
243
    ''.concat(
244
      fetchOptions.url,
245
      Object.keys(params).length === 0 || fetchOptions.url.endsWith('?')
3,046✔
246
        ? ''
247
        : '?',
248
      new URLSearchParams(params).toString(),
249
    ),
250
    { ...requestInit, redirect: 'manual' },
251
  );
252

253
  const contentType = response.headers.get('content-type') ?? '';
1,468✔
254
  const responseBytesBuffer = await response.arrayBuffer();
1,468✔
255

256
  const data = ((): SerializedData => {
1,468✔
257
    if (contentType.includes('application/json')) {
1,468✔
258
      const text = new TextDecoder().decode(responseBytesBuffer);
1,132✔
259
      return jsonToSerializedData(text);
1,132✔
260
    }
261
    return void 0;
336✔
262
  })();
263

264
  const content = generateByteStreamFromBuffer(responseBytesBuffer);
1,468✔
265

266
  let fetchResponse: FetchResponse = {
1,468✔
267
    status: response.status,
268
    data,
269
    content,
270
    headers: Object.fromEntries(Array.from(response.headers.entries())),
271
  };
272
  if (fetchOptions.networkSession?.interceptors?.length) {
1,468!
273
    fetchResponse = fetchOptions.networkSession?.interceptors.reduce(
8!
274
      (modifiedResponse: FetchResponse, interceptor: Interceptor) =>
275
        interceptor.afterRequest(modifiedResponse),
10✔
276
      fetchResponse,
277
    );
278
  }
279

280
  if (fetchResponse.status >= 300 && fetchResponse.status < 400) {
1,468✔
281
    if (!fetchResponse.headers['location']) {
12✔
282
      throw new BoxSdkError({
2✔
283
        message: `Unable to follow redirect for ${fetchOptions.url}`,
284
      });
285
    }
286
    return fetch({
10✔
287
      ...options,
288
      url: fetchResponse.headers['location'],
289
    });
290
  }
291

292
  const acceptedWithRetryAfter =
293
    fetchResponse.status === STATUS_CODE_ACCEPTED &&
1,456✔
294
    fetchResponse.headers['retry-after'];
295
  if (fetchResponse.status >= 400 || acceptedWithRetryAfter) {
1,456✔
296
    const { numRetries = 0 } = fetchOptions;
94✔
297

298
    const reauthenticationNeeded =
299
      fetchResponse.status == STATUS_CODE_UNAUTHORIZED;
94✔
300
    if (reauthenticationNeeded && fetchOptions.auth) {
94✔
301
      await fetchOptions.auth.refreshToken(fetchOptions.networkSession);
2✔
302

303
      // retry the request right away
304
      return fetch({
×
305
        ...options,
306
        numRetries: numRetries + 1,
307
        fileStream: fileStreamBuffer
×
308
          ? generateByteStreamFromBuffer(fileStreamBuffer)
309
          : void 0,
310
      });
311
    }
312

313
    const isRetryable =
314
      fetchOptions.contentType !== 'application/x-www-form-urlencoded' &&
92✔
315
      (fetchResponse.status === STATUS_CODE_TOO_MANY_REQUESTS ||
316
        acceptedWithRetryAfter ||
317
        fetchResponse.status >= 500);
318

319
    if (isRetryable && numRetries < DEFAULT_MAX_ATTEMPTS) {
92!
320
      const retryTimeout = fetchResponse.headers['retry-after']
×
321
        ? parseFloat(fetchResponse.headers['retry-after']!) * 1000
322
        : getRetryTimeout(numRetries, RETRY_BASE_INTERVAL * 1000);
323

324
      await new Promise((resolve) => setTimeout(resolve, retryTimeout));
×
325
      return fetch({ ...options, numRetries: numRetries + 1 });
×
326
    }
327

328
    const [code, contextInfo, requestId, helpUrl] = sdIsMap(fetchResponse.data)
92✔
329
      ? [
330
          sdToJson(fetchResponse.data['code']),
331
          sdIsMap(fetchResponse.data['context_info'])
76✔
332
            ? fetchResponse.data['context_info']
333
            : undefined,
334
          sdToJson(fetchResponse.data['request_id']),
335
          sdToJson(fetchResponse.data['help_url']),
336
        ]
337
      : [];
338

339
    throw new BoxApiError({
92✔
340
      message: `${fetchResponse.status}`,
341
      timestamp: `${Date.now()}`,
342
      requestInfo: {
343
        method: requestInit.method!,
344
        url: fetchOptions.url,
345
        queryParams: params,
346
        headers: (requestInit.headers as { [key: string]: string }) ?? {},
276!
347
        body:
348
          typeof requestInit.body === 'string' ? requestInit.body : undefined,
92✔
349
      },
350
      responseInfo: {
351
        statusCode: fetchResponse.status,
352
        headers: fetchResponse.headers,
353
        body: fetchResponse.data,
354
        rawBody: new TextDecoder().decode(responseBytesBuffer),
355
        code: code,
356
        contextInfo: contextInfo,
357
        requestId: requestId,
358
        helpUrl: helpUrl,
359
      },
360
      name: 'BoxApiError',
361
    });
362
  }
363

364
  return fetchResponse;
1,362✔
365
}
366

367
async function calculateMD5Hash(data: string | Buffer): Promise<string> {
368
  /**
369
   * Calculate the SHA1 hash of the data
370
   */
371
  let createHash: any;
372
  // Browser environment
373
  if (isBrowser()) {
98!
374
    let dataBuffer =
375
      typeof data === 'string' ? new TextEncoder().encode(data) : data;
×
376
    let hashBuffer = await window.crypto.subtle.digest('SHA-1', dataBuffer);
×
377
    let hashArray = Array.from(new Uint8Array(hashBuffer));
×
378
    let hashHex = hashArray
×
379
      .map((b) => b.toString(16).padStart(2, '0'))
×
380
      .join('');
381
    return hashHex;
×
382
  }
383

384
  // Node environment
385
  createHash = eval('require')('crypto').createHash;
98✔
386
  return createHash('sha1').update(data).digest('hex');
98✔
387
}
388

389
function constructBoxUAHeader() {
390
  const analyticsIdentifiers = {
140✔
391
    agent: `box-javascript-generated-sdk/${sdkVersion}`,
392
    env: isBrowser()
140!
393
      ? navigator.userAgent
394
      : `Node/${process.version.replace('v', '')}`,
395
  } as Record<string, string>;
396

397
  return Object.keys(analyticsIdentifiers)
140✔
398
    .map((k) => `${k}=${analyticsIdentifiers[k]}`)
280✔
399
    .join('; ');
400
}
401

402
// Retry intervals are between 50% and 150% of the exponentially increasing base amount
403
const RETRY_RANDOMIZATION_FACTOR = 0.5;
140✔
404

405
/**
406
 * Calculate the exponential backoff time with randomized jitter
407
 * @param {int} numRetries Which retry number this one will be
408
 * @param {int} baseInterval The base retry interval set in config
409
 * @returns {int} The number of milliseconds after which to retry
410
 */
411
export function getRetryTimeout(numRetries: number, baseInterval: number) {
140✔
NEW
412
  var minRandomization = 1 - RETRY_RANDOMIZATION_FACTOR;
×
NEW
413
  var maxRandomization = 1 + RETRY_RANDOMIZATION_FACTOR;
×
414
  var randomization =
NEW
415
    Math.random() * (maxRandomization - minRandomization) + minRandomization;
×
NEW
416
  var exponential = Math.pow(2, numRetries - 1);
×
NEW
417
  return Math.ceil(exponential * baseInterval * randomization);
×
418
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc