@compassdigital/sdk.typescript 4.72.0 → 4.74.0

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/src/base.ts CHANGED
@@ -1,31 +1,30 @@
1
-
2
- import fetch from "cross-fetch";
1
+ import fetch from 'cross-fetch';
3
2
 
4
3
  export interface RequestOptions {
5
- // environment to make requests to
6
- stage?: string;
7
- // authentication token
8
- token?: string;
9
- // additional headers
10
- headers?: Record<string, string>;
11
- // query parameters
12
- query?: { _provider?: string };
13
- // log all requests and responses
14
- debug?: boolean;
15
- // the number of times to retry a request
16
- retry?: number;
17
- // timeout in milliseconds
18
- timeout?: number;
19
- // make requests against this base url.
20
- // per-service base urls are configured as an object
21
- // eg. { order: 'http://localhost:3000', shoppingcart: 'http://localhost:4000' }
22
- // note: the stage property is ignored if base_url is set
23
- base_url?: string | Record<string, string>
24
- // intercept outgoing http requests
25
- intercept?: InterceptFn;
26
- // https.Agent agent to use
27
- // Note: we use 'any' type to remain browser compatible
28
- agent?: any;
4
+ // environment to make requests to
5
+ stage?: string;
6
+ // authentication token
7
+ token?: string;
8
+ // additional headers
9
+ headers?: Record<string, string>;
10
+ // query parameters
11
+ query?: { _provider?: string };
12
+ // log all requests and responses
13
+ debug?: boolean;
14
+ // the number of times to retry a request
15
+ retry?: number;
16
+ // timeout in milliseconds
17
+ timeout?: number;
18
+ // make requests against this base url.
19
+ // per-service base urls are configured as an object
20
+ // eg. { order: 'http://localhost:3000', shoppingcart: 'http://localhost:4000' }
21
+ // note: the stage property is ignored if base_url is set
22
+ base_url?: string | Record<string, string>;
23
+ // intercept outgoing http requests
24
+ intercept?: InterceptFn;
25
+ // https.Agent agent to use
26
+ // Note: we use 'any' type to remain browser compatible
27
+ agent?: any;
29
28
  }
30
29
 
31
30
  /**
@@ -33,12 +32,12 @@ export interface RequestOptions {
33
32
  * functions.
34
33
  */
35
34
  export interface RequestData {
36
- name: string;
37
- service: string;
38
- url: string;
39
- method: string;
40
- headers: Record<string, string>;
41
- body?: string;
35
+ route: string;
36
+ service: string;
37
+ url: string;
38
+ method: string;
39
+ headers: Record<string, string>;
40
+ body?: string;
42
41
  }
43
42
 
44
43
  /**
@@ -46,10 +45,10 @@ export interface RequestData {
46
45
  * intercept functions.
47
46
  */
48
47
  export interface ResponseData {
49
- ok: boolean;
50
- status: number;
51
- body: string;
52
- err?: any; // network error
48
+ ok: boolean;
49
+ status: number;
50
+ body: string;
51
+ err?: any; // network error
53
52
  }
54
53
 
55
54
  /**
@@ -67,207 +66,208 @@ export type InterceptFn = (req: RequestData, fetch: FetchFn) => Promise<Response
67
66
  * the existing Promise class due to: https://github.com/microsoft/TypeScript/issues/15202
68
67
  */
69
68
  export class ResponsePromise<T> implements Promise<T> {
70
-
71
- /**
72
- * Implements Promise interface.
73
- */
74
- [Symbol.toStringTag] = "[object ResponsePromise]";
75
-
76
- constructor(private promise: Promise<T>) { }
77
-
78
- /**
79
- * Implements Promise interface.
80
- */
81
- then<TResult1 = T, TResult2 = never>(
82
- onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
83
- onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
84
- ): ResponsePromise<TResult1 | TResult2> {
85
- return new ResponsePromise(this.promise.then(onfulfilled, onrejected));
86
- }
87
-
88
- /**
89
- * Implements Promise interface.
90
- */
91
- catch<TResult = never>(
92
- onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
93
- ): ResponsePromise<T | TResult> {
94
- return new ResponsePromise(this.promise.catch(onrejected));
95
- }
96
-
97
- /**
98
- * Implements Promise interface.
99
- */
100
- finally(onfinally?: (() => void) | null): ResponsePromise<T> {
101
- return new ResponsePromise(this.promise.finally(onfinally));
102
- }
103
-
104
- /**
105
- * Returns a promise that resolves to null if http status code
106
- * or service error code matches one of the provided codes.
107
- * If no codes are specified, all codes are ignored.
108
- */
109
- ignore(...codes: number[]): ResponsePromise<T | null> {
110
- return new ResponsePromise(ServiceError.ignore(codes, this.promise));
111
- }
112
-
113
- /**
114
- * Returns a promise that resolves to either the response body
115
- * or a ServiceError.
116
- */
117
- combine(...codes: number[]): Promise<EitherResponse<T>> {
118
- return new ResponsePromise(ServiceError.combine<T>(codes, this.promise));
119
- }
120
-
121
- /**
122
- * Creates a new resolved promise for the provided value.
123
- */
124
- static resolve<T>(value: T): ResponsePromise<T> {
125
- return new ResponsePromise(Promise.resolve(value));
126
- }
127
-
128
- /**
129
- * Creates a new rejected promise for the provided reason.
130
- * @param reason The reason the promise was rejected.
131
- * @returns A new rejected Promise.
132
- */
133
- static reject<T = never>(reason?: any): ResponsePromise<T> {
134
- return new ResponsePromise(Promise.reject(reason));
135
- }
69
+ /**
70
+ * Implements Promise interface.
71
+ */
72
+ [Symbol.toStringTag] = '[object ResponsePromise]';
73
+
74
+ constructor(private promise: Promise<T>) {}
75
+
76
+ /**
77
+ * Implements Promise interface.
78
+ */
79
+ then<TResult1 = T, TResult2 = never>(
80
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
81
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
82
+ ): ResponsePromise<TResult1 | TResult2> {
83
+ return new ResponsePromise(this.promise.then(onfulfilled, onrejected));
84
+ }
85
+
86
+ /**
87
+ * Implements Promise interface.
88
+ */
89
+ catch<TResult = never>(
90
+ onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
91
+ ): ResponsePromise<T | TResult> {
92
+ return new ResponsePromise(this.promise.catch(onrejected));
93
+ }
94
+
95
+ /**
96
+ * Implements Promise interface.
97
+ */
98
+ finally(onfinally?: (() => void) | null): ResponsePromise<T> {
99
+ return new ResponsePromise(this.promise.finally(onfinally));
100
+ }
101
+
102
+ /**
103
+ * Returns a promise that resolves to null if http status code
104
+ * or service error code matches one of the provided codes.
105
+ * If no codes are specified, all codes are ignored.
106
+ */
107
+ ignore(...codes: number[]): ResponsePromise<T | null> {
108
+ return new ResponsePromise(ServiceError.ignore(codes, this.promise));
109
+ }
110
+
111
+ /**
112
+ * Returns a promise that resolves to either the response body
113
+ * or a ServiceError.
114
+ */
115
+ combine(...codes: number[]): Promise<EitherResponse<T>> {
116
+ return new ResponsePromise(ServiceError.combine<T>(codes, this.promise));
117
+ }
118
+
119
+ /**
120
+ * Creates a new resolved promise for the provided value.
121
+ */
122
+ static resolve<T>(value: T): ResponsePromise<T> {
123
+ return new ResponsePromise(Promise.resolve(value));
124
+ }
125
+
126
+ /**
127
+ * Creates a new rejected promise for the provided reason.
128
+ * @param reason The reason the promise was rejected.
129
+ * @returns A new rejected Promise.
130
+ */
131
+ static reject<T = never>(reason?: any): ResponsePromise<T> {
132
+ return new ResponsePromise(Promise.reject(reason));
133
+ }
136
134
  }
137
135
 
138
136
  /**
139
137
  * Either a response body or a service error.
140
138
  */
141
- export type EitherResponse<T> = { ok: true; data: T; err: null } | { ok: false; data: null, err: ServiceError; };
139
+ export type EitherResponse<T> =
140
+ | { ok: true; data: T; err: null }
141
+ | { ok: false; data: null; err: ServiceError };
142
142
 
143
143
  /**
144
144
  * This is a hack to make captureStackTrace visible.
145
145
  */
146
146
  interface ErrorConstructor {
147
- captureStackTrace?(target: object, constructorOpt?: Function): void;
147
+ captureStackTrace?(target: object, constructorOpt?: Function): void;
148
148
  }
149
149
 
150
150
  /**
151
151
  * An error returned from an api service.
152
152
  */
153
153
  export class ServiceError extends Error {
154
- constructor(
155
- public status: number, // http status
156
- public code: number, // service error code
157
- message: string // service error message
158
- ) {
159
- super(message);
160
- this.name = `ServiceError(${code})`;
161
-
162
- // TODO: remove this if we change compilation target > es5
163
- Object.setPrototypeOf(this, ServiceError.prototype);
164
- }
165
-
166
- /**
167
- * Returns the http status code from err.
168
- * If err isn't an instance of ServiceError, -1 is returned.
169
- */
170
- static status(err: any): number {
171
- if (err instanceof ServiceError) {
172
- return err.status;
173
- }
174
- return -1;
175
- }
176
-
177
- /**
178
- * Returns the service error code from err.
179
- * If err isn't an instance of ServiceError, -1 is returned.
180
- */
181
- static code(err: any): number {
182
- if (err instanceof ServiceError) {
183
- return err.code;
184
- }
185
- return -1;
186
- }
187
-
188
- /**
189
- * Returns a promise that resolves to null if http status code
190
- * or service error code matches one of the provided codes.
191
- * If no codes are specified, all codes are ignored.
192
- */
193
- static async ignore<T>(codes: number[], response: Promise<T>): Promise<T | null> {
194
- try {
195
- return await response;
196
- } catch (err) {
197
- if (ServiceError.unwrap(err, codes)) {
198
- return null;
199
- }
200
- throw err;
201
- }
202
- }
203
-
204
- /**
205
- * Returns a promise that resolves to either T or a ServiceError.
206
- */
207
- static async combine<T>(codes: number[], response: Promise<T>): Promise<EitherResponse<T>> {
208
- try {
209
- const data = await response;
210
- return { ok: true, data, err: null };
211
- } catch (err) {
212
- const match = ServiceError.unwrap(err, codes);
213
- if (match) {
214
- return { ok: false, data: null, err: match };
215
- }
216
- throw err;
217
- }
218
- }
219
-
220
- /**
221
- * Returns the first ServiceError in the cause chain matches the provided codes.
222
- * If no codes were provided, the first ServiceError is returned.
223
- */
224
- private static unwrap(err: any, codes: number[]): ServiceError | null {
225
- while (err) {
226
- if (err instanceof ServiceError) {
227
- if (codes.length === 0) {
228
- return err
229
- }
230
- for (const code of codes) {
231
- if (err.status === code || err.code === code) {
232
- return err
233
- }
234
- }
235
- }
236
- err = err?.cause;
237
- }
238
- return null;
239
- }
240
-
241
- /**
242
- * Constructs a ServiceError from the provided response data.
243
- */
244
- static from_res(res: ResponseData): ServiceError {
245
- let message = res.body;
246
- let code = res.status;
247
- if (res.err) {
248
- // network error
249
- if (res.err instanceof Error) {
250
- message = res.err.message;
251
- } else {
252
- message = "Network Error";
253
- }
254
- code = 0;
255
- } else {
256
- try {
257
- let data = JSON.parse(res.body);
258
- if (data.code && (data.message || data.error)) {
259
- code = data.code;
260
- message = data.message || data.error;
261
- }
262
- } catch (_) { }
263
- }
264
- const err = new ServiceError(res.status, code, message);
265
- // override the stack trace so that it doesn't include the from_res helper.
266
- if (Error.captureStackTrace) {
267
- Error.captureStackTrace(err, ServiceError.from_res);
268
- }
269
- return err;
270
- }
154
+ constructor(
155
+ public status: number, // http status
156
+ public code: number, // service error code
157
+ message: string, // service error message
158
+ ) {
159
+ super(message);
160
+ this.name = `ServiceError(${code})`;
161
+
162
+ // TODO: remove this if we change compilation target > es5
163
+ Object.setPrototypeOf(this, ServiceError.prototype);
164
+ }
165
+
166
+ /**
167
+ * Returns the http status code from err.
168
+ * If err isn't an instance of ServiceError, -1 is returned.
169
+ */
170
+ static status(err: any): number {
171
+ if (err instanceof ServiceError) {
172
+ return err.status;
173
+ }
174
+ return -1;
175
+ }
176
+
177
+ /**
178
+ * Returns the service error code from err.
179
+ * If err isn't an instance of ServiceError, -1 is returned.
180
+ */
181
+ static code(err: any): number {
182
+ if (err instanceof ServiceError) {
183
+ return err.code;
184
+ }
185
+ return -1;
186
+ }
187
+
188
+ /**
189
+ * Returns a promise that resolves to null if http status code
190
+ * or service error code matches one of the provided codes.
191
+ * If no codes are specified, all codes are ignored.
192
+ */
193
+ static async ignore<T>(codes: number[], response: Promise<T>): Promise<T | null> {
194
+ try {
195
+ return await response;
196
+ } catch (err) {
197
+ if (ServiceError.unwrap(err, codes)) {
198
+ return null;
199
+ }
200
+ throw err;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Returns a promise that resolves to either T or a ServiceError.
206
+ */
207
+ static async combine<T>(codes: number[], response: Promise<T>): Promise<EitherResponse<T>> {
208
+ try {
209
+ const data = await response;
210
+ return { ok: true, data, err: null };
211
+ } catch (err) {
212
+ const match = ServiceError.unwrap(err, codes);
213
+ if (match) {
214
+ return { ok: false, data: null, err: match };
215
+ }
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Returns the first ServiceError in the cause chain matches the provided codes.
222
+ * If no codes were provided, the first ServiceError is returned.
223
+ */
224
+ private static unwrap(err: any, codes: number[]): ServiceError | null {
225
+ while (err) {
226
+ if (err instanceof ServiceError) {
227
+ if (codes.length === 0) {
228
+ return err;
229
+ }
230
+ for (const code of codes) {
231
+ if (err.status === code || err.code === code) {
232
+ return err;
233
+ }
234
+ }
235
+ }
236
+ err = err?.cause;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * Constructs a ServiceError from the provided response data.
243
+ */
244
+ static from_res(res: ResponseData): ServiceError {
245
+ let message = res.body;
246
+ let code = res.status;
247
+ if (res.err) {
248
+ // network error
249
+ if (res.err instanceof Error) {
250
+ message = res.err.message;
251
+ } else {
252
+ message = 'Network Error';
253
+ }
254
+ code = 0;
255
+ } else {
256
+ try {
257
+ const data = JSON.parse(res.body);
258
+ if (data.code && (data.message || data.error)) {
259
+ code = data.code;
260
+ message = data.message || data.error;
261
+ }
262
+ } catch (_) {}
263
+ }
264
+ const err = new ServiceError(res.status, code, message);
265
+ // override the stack trace so that it doesn't include the from_res helper.
266
+ if (Error.captureStackTrace) {
267
+ Error.captureStackTrace(err, ServiceError.from_res);
268
+ }
269
+ return err;
270
+ }
271
271
  }
272
272
 
273
273
  /**
@@ -275,231 +275,238 @@ export class ServiceError extends Error {
275
275
  * This class is meant to be extended by the generated code.
276
276
  */
277
277
  export abstract class BaseServiceClient {
278
- private options: RequestOptions;
279
-
280
- /**
281
- * shared default https.Agent
282
- * Note: we use 'any' type to remain browser compatible
283
- */
284
- private static agent: any;
285
-
286
- constructor(options?: RequestOptions) {
287
- this.options = {
288
- ...this.env_options(),
289
- ...options,
290
- };
291
- }
292
-
293
- /**
294
- * Parse request options from environment variables.
295
- */
296
- private env_options(): RequestOptions {
297
- const options: RequestOptions = {};
298
- const stage = process.env.P2_SDK_STAGE;
299
- if (stage) {
300
- options.stage = stage;
301
- }
302
- const token = process.env.P2_SDK_TOKEN;
303
- if (token) {
304
- options.token = token;
305
- }
306
- const debug = process.env.P2_SDK_DEBUG;
307
- if (debug && debug !== "0" && `${debug}`.toLowerCase() !== "false") {
308
- options.debug = true;
309
- }
310
- const base_url = process.env.P2_SDK_BASE_URL;
311
- if (base_url) {
312
- if (base_url.includes('=')) {
313
- options.base_url = {};
314
- for (const custom_service_url of base_url.split(',')) {
315
- const [service, custom_url] = custom_service_url.split('=');
316
- options.base_url[service] = custom_url
317
- }
318
- } else {
319
- options.base_url = base_url;
320
- }
321
- }
322
- return options;
323
- }
324
-
325
- /**
326
- * Combine the route and query parameters into a full url.
327
- */
328
- private build_url(route: string, service: string, { base_url, stage = "dev" }: RequestOptions, params: any): string {
329
- let url = stage === 'v1'
330
- ? `https://api.compassdigital.org${route}`
331
- : `https://${stage}.api.compassdigital.org${route}`;
332
- // use the base url if one was provided.
333
- if (base_url) {
334
- if (typeof base_url === 'object' && base_url[service]) base_url = base_url[service];
335
- if (typeof base_url === 'string') url = `${this.clean_url(base_url)}${route}`;
336
- }
337
- // add query parameters.
338
- const query: string[] = [];
339
- for (const [name, values] of Object.entries(params)) {
340
- for (const value of Array.isArray(values) ? values : [values]) {
341
- query.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
342
- }
343
- }
344
- if (query.length > 0) {
345
- url += "?" + query.join("&");
346
- }
347
- return url;
348
- }
349
-
350
- private clean_url(url: string): string {
351
- if (url.endsWith("/")) {
352
- url = url.slice(0, -1);
353
- }
354
- return url;
355
- }
356
-
357
- /**
358
- * Perform the http request or pass it to the intercept function
359
- * if one was configured.
360
- */
361
- private async fetch(req: RequestData, options?: RequestOptions): Promise<ResponseData> {
362
- if (options?.intercept) {
363
- return options.intercept(req, this.fetch.bind(this));
364
- }
365
- try {
366
- let signal: AbortSignal | undefined;
367
- if (options?.timeout) {
368
- const controller = new AbortController();
369
- setTimeout(() => controller.abort("timeout"), options.timeout);
370
- signal = controller.signal;
371
- }
372
- const res = await fetch(req.url, {
373
- method: req.method,
374
- body: req.body,
375
- headers: req.headers,
376
- // @ts-expect-error
377
- agent: options?.agent,
378
- signal,
379
- });
380
- return {
381
- ok: res.ok,
382
- status: res.status,
383
- body: await res.text(),
384
- };
385
- } catch (err) {
386
- return {
387
- ok: false,
388
- status: 0,
389
- body: "",
390
- err: err,
391
- }
392
- }
393
- }
394
-
395
- /**
396
- * Get default https.Agent or undefined
397
- */
398
- private agent(): any {
399
- if (process?.release?.name === "node" && !BaseServiceClient.agent) {
400
- const https = require("https");
401
- BaseServiceClient.agent = new https.Agent({ keepAlive: true });
402
- }
403
- return BaseServiceClient.agent;
404
- }
405
-
406
- /**
407
- * Returns true if the status code represents a transient error
408
- * or it's a network error.
409
- */
410
- private should_retry(res: ResponseData): boolean {
411
- if (res.err) {
412
- return true;
413
- }
414
- switch (res.status) {
415
- case 408: // timeout
416
- case 413: // too large
417
- case 429: // too many requests
418
- case 500: // generic error response
419
- case 502: // invalid upstream response
420
- case 503: // service unavailable
421
- case 504: // gateway timeout
422
- case 522: // tcp connection timeout
423
- return true;
424
- default:
425
- return false;
426
- }
427
- }
428
-
429
- /**
430
- * Returns the merged options.
431
- */
432
- private get_options(options: RequestOptions): RequestOptions {
433
- return {
434
- ...this.options,
435
- ...options,
436
- headers: {
437
- ...this.options.headers,
438
- ...options.headers,
439
- }
440
- };
441
- }
442
-
443
- /**
444
- * A delegate to _request that wraps the response in a ResponsePromise.
445
- */
446
- protected request(
447
- service: string,
448
- name: string,
449
- method: string,
450
- route: string,
451
- body: any,
452
- options: { query?: any } & RequestOptions = {}
453
- ): ResponsePromise<any> {
454
- const promise = this._request(service, name, method, route, body, options);
455
- return new ResponsePromise(promise);
456
- }
457
-
458
- /**
459
- * The main logic for fulfilling a request as perscribed by
460
- * the request options.
461
- */
462
- private async _request(
463
- service: string,
464
- name: string,
465
- method: string,
466
- route: string,
467
- body: any,
468
- options: { query?: any } & RequestOptions = {}
469
- ): Promise<any> {
470
- options = this.get_options(options);
471
- const url = this.build_url(route, service, options, options.query ?? {});
472
- if (url.startsWith('https://')) options.agent ??= this.agent();
473
- const headers = Object.assign({
474
- "User-Agent": "CDL/ServiceClient",
475
- "Content-Type": "application/json",
476
- }, options.headers);
477
- if (options.token) {
478
- headers["Authorization"] = `Bearer ${options.token}`;
479
- }
480
- const req: RequestData = { name, service, url, method, headers };
481
- if (body) {
482
- req.body = JSON.stringify(body);
483
- }
484
- if (options.debug) {
485
- console.log(`REQ(${method.toUpperCase()})`, url, JSON.stringify(req, null, 2));
486
- }
487
- const res = await this.fetch(req, options);
488
- if (!res.ok) {
489
- const err = ServiceError.from_res(res);
490
- if (options.debug) {
491
- console.log(`ERR(${res.status})`, url, err.message);
492
- }
493
- if (typeof options.retry === "number" && options.retry > 0 && this.should_retry(res)) {
494
- options.retry--;
495
- return this.request(service, name, method, route, body, options);
496
- }
497
- throw err;
498
- }
499
- const data = res.body ? JSON.parse(res.body) : res.body;
500
- if (options.debug) {
501
- console.log(`RES(${res.status})`, url, JSON.stringify(data, null, 2));
502
- }
503
- return data;
504
- }
278
+ private options: RequestOptions;
279
+
280
+ /**
281
+ * shared default https.Agent
282
+ * Note: we use 'any' type to remain browser compatible
283
+ */
284
+ private static agent: any;
285
+
286
+ constructor(options?: RequestOptions) {
287
+ this.options = {
288
+ ...this.env_options(),
289
+ ...options,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Parse request options from environment variables.
295
+ */
296
+ private env_options(): RequestOptions {
297
+ const options: RequestOptions = {};
298
+ const stage = process.env.P2_SDK_STAGE;
299
+ if (stage) {
300
+ options.stage = stage;
301
+ }
302
+ const token = process.env.P2_SDK_TOKEN;
303
+ if (token) {
304
+ options.token = token;
305
+ }
306
+ const debug = process.env.P2_SDK_DEBUG;
307
+ if (debug && debug !== '0' && `${debug}`.toLowerCase() !== 'false') {
308
+ options.debug = true;
309
+ }
310
+ const base_url = process.env.P2_SDK_BASE_URL;
311
+ if (base_url) {
312
+ if (base_url.includes('=')) {
313
+ options.base_url = {};
314
+ for (const custom_service_url of base_url.split(',')) {
315
+ const [service, custom_url] = custom_service_url.split('=');
316
+ options.base_url[service] = custom_url;
317
+ }
318
+ } else {
319
+ options.base_url = base_url;
320
+ }
321
+ }
322
+ return options;
323
+ }
324
+
325
+ /**
326
+ * Combine the path and query parameters into a full url.
327
+ */
328
+ private build_url(
329
+ path: string,
330
+ service: string,
331
+ { base_url, stage = 'dev' }: RequestOptions,
332
+ params: any,
333
+ ): string {
334
+ let url =
335
+ stage === 'v1'
336
+ ? `https://api.compassdigital.org${path}`
337
+ : `https://${stage}.api.compassdigital.org${path}`;
338
+ // use the base url if one was provided.
339
+ if (base_url) {
340
+ if (typeof base_url === 'object' && base_url[service]) base_url = base_url[service];
341
+ if (typeof base_url === 'string') url = `${this.clean_url(base_url)}${path}`;
342
+ }
343
+ // add query parameters.
344
+ const query: string[] = [];
345
+ for (const [name, values] of Object.entries(params)) {
346
+ for (const value of Array.isArray(values) ? values : [values]) {
347
+ query.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
348
+ }
349
+ }
350
+ if (query.length > 0) {
351
+ url += `?${query.join('&')}`;
352
+ }
353
+ return url;
354
+ }
355
+
356
+ private clean_url(url: string): string {
357
+ if (url.endsWith('/')) {
358
+ url = url.slice(0, -1);
359
+ }
360
+ return url;
361
+ }
362
+
363
+ /**
364
+ * Perform the http request or pass it to the intercept function
365
+ * if one was configured.
366
+ */
367
+ private async fetch(req: RequestData, options?: RequestOptions): Promise<ResponseData> {
368
+ if (options?.intercept) {
369
+ return options.intercept(req, this.fetch.bind(this));
370
+ }
371
+ try {
372
+ let signal: AbortSignal | undefined;
373
+ if (options?.timeout) {
374
+ const controller = new AbortController();
375
+ setTimeout(() => controller.abort('timeout'), options.timeout);
376
+ signal = controller.signal;
377
+ }
378
+ const res = await fetch(req.url, {
379
+ method: req.method,
380
+ body: req.body,
381
+ headers: req.headers,
382
+ // @ts-expect-error
383
+ agent: options?.agent,
384
+ signal,
385
+ });
386
+ return {
387
+ ok: res.ok,
388
+ status: res.status,
389
+ body: await res.text(),
390
+ };
391
+ } catch (err) {
392
+ return {
393
+ ok: false,
394
+ status: 0,
395
+ body: '',
396
+ err,
397
+ };
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Get default https.Agent or undefined
403
+ */
404
+ private agent(): any {
405
+ if (process?.release?.name === 'node' && !BaseServiceClient.agent) {
406
+ const https = require('https');
407
+ BaseServiceClient.agent = new https.Agent({ keepAlive: true });
408
+ }
409
+ return BaseServiceClient.agent;
410
+ }
411
+
412
+ /**
413
+ * Returns true if the status code represents a transient error
414
+ * or it's a network error.
415
+ */
416
+ private should_retry(res: ResponseData): boolean {
417
+ if (res.err) {
418
+ return true;
419
+ }
420
+ switch (res.status) {
421
+ case 408: // timeout
422
+ case 413: // too large
423
+ case 429: // too many requests
424
+ case 500: // generic error response
425
+ case 502: // invalid upstream response
426
+ case 503: // service unavailable
427
+ case 504: // gateway timeout
428
+ case 522: // tcp connection timeout
429
+ return true;
430
+ default:
431
+ return false;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Returns the merged options.
437
+ */
438
+ private get_options(options: RequestOptions): RequestOptions {
439
+ return {
440
+ ...this.options,
441
+ ...options,
442
+ headers: {
443
+ ...this.options.headers,
444
+ ...options.headers,
445
+ },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * A delegate to _request that wraps the response in a ResponsePromise.
451
+ */
452
+ protected request(
453
+ service: string,
454
+ route: string,
455
+ method: string,
456
+ path: string,
457
+ body: any,
458
+ options: { query?: any } & RequestOptions = {},
459
+ ): ResponsePromise<any> {
460
+ const promise = this._request(service, route, method, path, body, options);
461
+ return new ResponsePromise(promise);
462
+ }
463
+
464
+ /**
465
+ * The main logic for fulfilling a request as perscribed by
466
+ * the request options.
467
+ */
468
+ private async _request(
469
+ service: string,
470
+ route: string,
471
+ method: string,
472
+ path: string,
473
+ body: any,
474
+ options: { query?: any } & RequestOptions = {},
475
+ ): Promise<any> {
476
+ options = this.get_options(options);
477
+ const url = this.build_url(path, service, options, options.query ?? {});
478
+ if (url.startsWith('https://')) options.agent ??= this.agent();
479
+ const headers: Record<string, string> = {
480
+ 'User-Agent': 'CDL/ServiceClient',
481
+ 'Content-Type': 'application/json',
482
+ ...options.headers,
483
+ };
484
+ if (options.token) {
485
+ headers.Authorization = `Bearer ${options.token}`;
486
+ }
487
+ const req: RequestData = { service, route, url, method, headers };
488
+ if (body) {
489
+ req.body = JSON.stringify(body);
490
+ }
491
+ if (options.debug) {
492
+ console.log(`REQ(${method.toUpperCase()})`, url, JSON.stringify(req, null, 2));
493
+ }
494
+ const res = await this.fetch(req, options);
495
+ if (!res.ok) {
496
+ const err = ServiceError.from_res(res);
497
+ if (options.debug) {
498
+ console.log(`ERR(${res.status})`, url, err.message);
499
+ }
500
+ if (typeof options.retry === 'number' && options.retry > 0 && this.should_retry(res)) {
501
+ options.retry--;
502
+ return this._request(service, route, method, path, body, options);
503
+ }
504
+ throw err;
505
+ }
506
+ const data = res.body ? JSON.parse(res.body) : res.body;
507
+ if (options.debug) {
508
+ console.log(`RES(${res.status})`, url, JSON.stringify(data, null, 2));
509
+ }
510
+ return data;
511
+ }
505
512
  }