@xh/hoist 67.0.0-SNAPSHOT.1723668375053 → 67.0.0-SNAPSHOT.1723839594443

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.
@@ -7,15 +7,15 @@
7
7
  import {
8
8
  Awaitable,
9
9
  Exception,
10
- FetchResponse,
11
10
  HoistService,
12
11
  LoadSpec,
13
12
  PlainObject,
13
+ TrackOptions,
14
14
  XH
15
15
  } from '@xh/hoist/core';
16
16
  import {PromiseTimeoutSpec} from '@xh/hoist/promise';
17
17
  import {isLocalDate, SECONDS} from '@xh/hoist/utils/datetime';
18
- import {apiDeprecated} from '@xh/hoist/utils/js';
18
+ import {apiDeprecated, warnIf} from '@xh/hoist/utils/js';
19
19
  import {StatusCodes} from 'http-status-codes';
20
20
  import {isDate, isFunction, isNil, isObject, isString, omit, omitBy} from 'lodash';
21
21
  import {IStringifyOptions, stringify} from 'qs';
@@ -45,7 +45,11 @@ export class FetchService extends HoistService {
45
45
  NO_JSON_RESPONSES = [StatusCodes.NO_CONTENT, StatusCodes.RESET_CONTENT];
46
46
 
47
47
  private autoAborters = {};
48
-
48
+ private _defaultHeaders: DefaultHeaders[] = [];
49
+ private _interceptors: FetchInterceptor[] = [];
50
+ //-----------------------------------
51
+ // Public properties, Getters/Setters
52
+ //------------------------------------
49
53
  /** True to auto-generate a Correlation ID for each request unless otherwise specified. */
50
54
  autoGenCorrelationIds = false;
51
55
 
@@ -53,97 +57,87 @@ export class FetchService extends HoistService {
53
57
  * Method for generating Correlation ID's. Defaults to `XH.genUUID()` but can be modified
54
58
  * by applications looking to customize the format of their Correlation ID's.
55
59
  */
56
- genCorrelationId = () => XH.genUUID();
60
+ genCorrelationId: () => string = () => XH.genUUID();
57
61
 
58
62
  /** Request header name to be used for Correlation ID tracking. */
59
63
  correlationIdHeaderKey: string = 'X-Correlation-ID';
60
64
 
61
- /**
62
- * Timeout to be used for all requests made via this service that do not themselves spec a
63
- * custom timeout.
64
- */
65
+ /** Default timeout to be used for all requests made via this service */
65
66
  defaultTimeout: PromiseTimeoutSpec = 30 * SECONDS;
66
67
 
67
- private _defaultHeaders: Array<PlainObject | ((arg: FetchOptions) => Awaitable<PlainObject>)> =
68
- [];
69
-
70
- get defaultHeaders(): Array<PlainObject | ((arg: FetchOptions) => Awaitable<PlainObject>)> {
68
+ /** Default headers to be sent with all subsequent requests. */
69
+ get defaultHeaders(): DefaultHeaders[] {
71
70
  return this._defaultHeaders;
72
71
  }
73
72
 
74
73
  /**
75
- * Set default headers to be sent with all subsequent requests.
76
- * @param headers - to be sent with all fetch requests, or a function to generate.
77
- * @deprecated use addDefaultHeaders instead.
74
+ * Promise handlers to be executed before fufilling or rejecting returned Promise.
75
+ *
76
+ * Use the `onRejected` handler for apps requiring common handling for particular exceptions.
77
+ * Useful for recognizing 401s (i.e. session end), or wrapping, logging, or enhancing exceptions.
78
+ * The simplest onRejected handler will simply rethrow the passed exception, or a wrapped version of it.
79
+ * Such handlers may also return `never()` to prevent further processing of the request -- this
80
+ * is useful, i.e. if the handler is going to redirect the entire app, or otherwise end normal
81
+ * app processing. Rejected handlers may also be able to retry and return valid results via
82
+ * another call to fetch.
83
+ *
84
+ * Use the `onFulfilled` hander for enhancing, tracking, or even rejecting "successful" returns.
85
+ * For example, a handler of this form could be used to transform a 200 response returned by
86
+ * an API with an "error" flag into a proper client-side exception.
78
87
  */
79
- setDefaultHeaders(headers: PlainObject | ((arg: FetchOptions) => Awaitable<PlainObject>)) {
80
- apiDeprecated('setDefaultHeaders', {v: '66', msg: 'Use addDefaultHeaders instead'});
81
- this.addDefaultHeaders(headers);
88
+ addInterceptor(handler: FetchInterceptor) {
89
+ this._interceptors.push(handler);
82
90
  }
83
91
 
84
92
  /**
85
93
  * Add default headers to be sent with all subsequent requests.
86
94
  * @param headers - to be sent with all fetch requests, or a function to generate.
87
95
  */
88
- addDefaultHeaders(headers: PlainObject | ((arg: FetchOptions) => Awaitable<PlainObject>)) {
96
+ addDefaultHeaders(headers: DefaultHeaders) {
89
97
  this._defaultHeaders.push(headers);
90
98
  }
91
99
 
92
- /**
93
- * Set the timeout (default 30 seconds) to be used for all requests made via this service that
94
- * do not themselves spec a custom timeout.
95
- * @deprecated modify `defaultTimeout` directly instead.
96
- */
97
- setDefaultTimeout(timeout: PromiseTimeoutSpec) {
98
- apiDeprecated('setDefaultTimeout', {
99
- v: '68',
100
- msg: 'Modify `defaultTimeout` directly instead.'
101
- });
102
- this.defaultTimeout = timeout;
103
- }
104
-
100
+ //--------------------
101
+ // Main Entry Points
102
+ //--------------------
105
103
  /**
106
104
  * Send a request via the underlying fetch API.
107
- * @returns Promise which resolves to a Fetch Response.
105
+ *
106
+ * This is the main entry point for this API, and can be used to satisfy all
107
+ * requests. Other shortcut variants will delegate to this method, after setting
108
+ * default options and pre-processing content.
109
+ *
110
+ * Set `asJson` to true return a parsed JSON result, rather than the raw Response.
111
+ * Note that shortcut variant of this method (e.g. `fetchJson`, `postJson`) will set this
112
+ * flag for you.
113
+ *
114
+ * @returns Promise which resolves to a Response or JSON.
108
115
  */
109
- fetch(opts: FetchOptions): Promise<FetchResponse> {
110
- opts = this.withCorrelationId(opts);
111
- const ret = this.withDefaultHeadersAsync(opts).then(opts => this.managedFetchAsync(opts));
112
- ret.correlationId = opts.correlationId as string;
113
- return ret;
116
+ async fetch(opts: FetchOptions): Promise<any> {
117
+ return this.fetchInternalAsync(opts);
114
118
  }
115
119
 
116
120
  /**
117
121
  * Send an HTTP request and decode the response as JSON.
118
122
  * @returns the decoded JSON object, or null if the response has status in {@link NO_JSON_RESPONSES}.
119
123
  */
120
- fetchJson(opts: FetchOptions): Promise<any> {
121
- opts = this.withCorrelationId(opts);
122
- const ret = this.withDefaultHeadersAsync(opts, {Accept: 'application/json'}).then(opts =>
123
- this.managedFetchAsync(opts, async r => {
124
- if (this.NO_JSON_RESPONSES.includes(r.status)) return null;
125
- return r.json().catchWhen('SyntaxError', e => {
126
- throw Exception.fetchJsonParseError(opts, e);
127
- });
128
- })
129
- );
130
- ret.correlationId = opts.correlationId as string;
131
- return ret;
124
+ async fetchJson(opts: FetchOptions): Promise<any> {
125
+ return this.fetchInternalAsync({asJson: true, ...opts});
132
126
  }
133
127
 
134
128
  /**
135
129
  * Send a GET request and decode the response as JSON.
136
130
  * @returns the decoded JSON object, or null if the response status is in {@link NO_JSON_RESPONSES}.
137
131
  */
138
- getJson(opts: FetchOptions): Promise<any> {
139
- return this.fetchJson({method: 'GET', ...opts});
132
+ async getJson(opts: FetchOptions): Promise<any> {
133
+ return this.fetchInternalAsync({asJson: true, method: 'GET', ...opts});
140
134
  }
141
135
 
142
136
  /**
143
137
  * Send a POST request with a JSON body and decode the response as JSON.
144
138
  * @returns the decoded JSON object, or null if the response status is in {@link NO_JSON_RESPONSES}.
145
139
  */
146
- postJson(opts: FetchOptions): Promise<any> {
140
+ async postJson(opts: FetchOptions): Promise<any> {
147
141
  return this.sendJsonInternalAsync({method: 'POST', ...opts});
148
142
  }
149
143
 
@@ -151,7 +145,7 @@ export class FetchService extends HoistService {
151
145
  * Send a PUT request with a JSON body and decode the response as JSON.
152
146
  * @returns the decoded JSON object, or null if the response status is in {@link NO_JSON_RESPONSES}.
153
147
  */
154
- putJson(opts: FetchOptions): Promise<any> {
148
+ async putJson(opts: FetchOptions): Promise<any> {
155
149
  return this.sendJsonInternalAsync({method: 'PUT', ...opts});
156
150
  }
157
151
 
@@ -159,7 +153,7 @@ export class FetchService extends HoistService {
159
153
  * Send a PATCH request with a JSON body and decode the response as JSON.
160
154
  * @returns the decoded JSON object, or null if the response status is in {@link NO_JSON_RESPONSES}.
161
155
  */
162
- patchJson(opts: FetchOptions): Promise<any> {
156
+ async patchJson(opts: FetchOptions): Promise<any> {
163
157
  return this.sendJsonInternalAsync({method: 'PATCH', ...opts});
164
158
  }
165
159
 
@@ -167,7 +161,7 @@ export class FetchService extends HoistService {
167
161
  * Send a DELETE request with optional JSON body and decode the optional response as JSON.
168
162
  * @returns the decoded JSON object, or null if the response status is in {@link NO_JSON_RESPONSES}.
169
163
  */
170
- deleteJson(opts: FetchOptions): Promise<any> {
164
+ async deleteJson(opts: FetchOptions): Promise<any> {
171
165
  return this.sendJsonInternalAsync({method: 'DELETE', ...opts});
172
166
  }
173
167
 
@@ -186,11 +180,75 @@ export class FetchService extends HoistService {
186
180
  return true;
187
181
  }
188
182
 
183
+ //-------------
184
+ // Deprecations
185
+ //-------------
186
+ /**
187
+ * Set the timeout (default 30 seconds) to be used for all requests made via this service that
188
+ * do not themselves spec a custom timeout.
189
+ * @deprecated modify `defaultTimeout` directly instead.
190
+ */
191
+ setDefaultTimeout(timeout: PromiseTimeoutSpec) {
192
+ apiDeprecated('setDefaultTimeout', {
193
+ v: '68',
194
+ msg: 'Modify `defaultTimeout` directly instead.'
195
+ });
196
+ this.defaultTimeout = timeout;
197
+ }
198
+
199
+ /**
200
+ * Set default headers to be sent with all subsequent requests.
201
+ * @param headers - to be sent with all fetch requests, or a function to generate.
202
+ * @deprecated use addDefaultHeaders instead.
203
+ */
204
+ setDefaultHeaders(headers: DefaultHeaders) {
205
+ apiDeprecated('setDefaultHeaders', {v: '66', msg: 'Use addDefaultHeaders instead'});
206
+ this.addDefaultHeaders(headers);
207
+ }
208
+
189
209
  //-----------------------
190
210
  // Implementation
191
211
  //-----------------------
212
+ private async fetchInternalAsync(opts: FetchOptions): Promise<any> {
213
+ opts = this.withCorrelationId(opts);
214
+ opts = await this.withDefaultHeadersAsync(opts);
215
+ let ret = this.managedFetchAsync(opts);
216
+
217
+ // Apply tracking
218
+ const {correlationId, loadSpec, track} = opts;
219
+ if (track) {
220
+ const trackOptions = isString(track) ? {message: track} : track;
221
+ warnIf(
222
+ trackOptions.correlationId || trackOptions.loadSpec,
223
+ 'Neither Correlation ID nor LoadSpec should be set in `FetchOptions.track`. Use `FetchOptions` top-level properties instead.'
224
+ );
225
+ ret = ret.track({...trackOptions, correlationId: correlationId as string, loadSpec});
226
+ }
227
+
228
+ // Apply interceptors
229
+ for (const interceptor of this._interceptors) {
230
+ ret = ret.then(
231
+ value => interceptor.onFulfilled(opts, value),
232
+ cause => interceptor.onRejected(opts, cause)
233
+ );
234
+ }
235
+
236
+ return ret;
237
+ }
238
+
239
+ private sendJsonInternalAsync(opts: FetchOptions) {
240
+ return this.fetchInternalAsync({
241
+ asJson: true,
242
+ ...opts,
243
+ body: JSON.stringify(opts.body),
244
+ headers: {
245
+ 'Content-Type': 'application/json',
246
+ ...opts.headers
247
+ }
248
+ });
249
+ }
192
250
 
193
- /** Resolve convenience options for Correlation ID to server-ready string */
251
+ // Resolve convenience options for Correlation ID to server-ready string
194
252
  private withCorrelationId(opts: FetchOptions): FetchOptions {
195
253
  const {correlationId} = opts;
196
254
  if (isString(correlationId)) return opts;
@@ -201,10 +259,7 @@ export class FetchService extends HoistService {
201
259
  return opts;
202
260
  }
203
261
 
204
- private async withDefaultHeadersAsync(
205
- opts: FetchOptions,
206
- extraHeaders: PlainObject = null
207
- ): Promise<FetchOptions> {
262
+ private async withDefaultHeadersAsync(opts: FetchOptions): Promise<FetchOptions> {
208
263
  const method = opts.method ?? (opts.params ? 'POST' : 'GET'),
209
264
  isPost = method === 'POST';
210
265
 
@@ -216,7 +271,7 @@ export class FetchService extends HoistService {
216
271
  const headers = {
217
272
  'Content-Type': isPost ? 'application/x-www-form-urlencoded' : 'text/plain',
218
273
  ...defaultHeaders,
219
- ...extraHeaders,
274
+ ...(opts.asJson ? {Accept: 'application/json'} : {}),
220
275
  ...opts.headers
221
276
  };
222
277
 
@@ -234,10 +289,7 @@ export class FetchService extends HoistService {
234
289
  return {...opts, method, headers};
235
290
  }
236
291
 
237
- private async managedFetchAsync(
238
- opts: FetchOptions,
239
- postProcess: (r: FetchResponse) => Awaitable<FetchResponse> = null
240
- ): Promise<FetchResponse> {
292
+ private async managedFetchAsync(opts: FetchOptions): Promise<any> {
241
293
  // Prepare auto-aborter
242
294
  const {autoAborters, defaultTimeout} = this,
243
295
  {autoAbortKey, timeout = defaultTimeout} = opts,
@@ -250,7 +302,9 @@ export class FetchService extends HoistService {
250
302
  }
251
303
 
252
304
  try {
253
- return await this.fetchInternalAsync(opts, aborter).then(postProcess).timeout(timeout);
305
+ return await this.abortableFetchAsync(opts, aborter)
306
+ .then(opts.asJson ? r => this.parseJsonAsync(opts, r) : null)
307
+ .timeout(timeout);
254
308
  } catch (e) {
255
309
  if (e.isTimeout) {
256
310
  aborter.abort();
@@ -261,12 +315,13 @@ export class FetchService extends HoistService {
261
315
  throw Exception.fetchTimeout(opts, e, msg);
262
316
  }
263
317
 
264
- if (e.isHoistException) throw e;
265
-
266
- // Just two other cases where we expect this to *throw* -- Typically we get a fail status
267
- throw e.name === 'AbortError'
268
- ? Exception.fetchAborted(opts, e)
269
- : Exception.serverUnavailable(opts, e);
318
+ if (!e.isHoistException) {
319
+ // Just two other cases where we expect this to *throw* -- Typically we get a fail status
320
+ throw e.name === 'AbortError'
321
+ ? Exception.fetchAborted(opts, e)
322
+ : Exception.serverUnavailable(opts, e);
323
+ }
324
+ throw e;
270
325
  } finally {
271
326
  if (autoAborters[autoAbortKey] === aborter) {
272
327
  delete autoAborters[autoAbortKey];
@@ -274,10 +329,10 @@ export class FetchService extends HoistService {
274
329
  }
275
330
  }
276
331
 
277
- private async fetchInternalAsync(
332
+ private async abortableFetchAsync(
278
333
  opts: FetchOptions,
279
334
  aborter: AbortController
280
- ): Promise<FetchResponse> {
335
+ ): Promise<Response> {
281
336
  // 1) Prepare URL
282
337
  let {url, method, headers, body, params} = opts,
283
338
  isRelativeUrl = !url.startsWith('/') && !url.includes('//');
@@ -318,24 +373,17 @@ export class FetchService extends HoistService {
318
373
  }
319
374
 
320
375
  // 4) Await underlying fetch and post-process response.
321
- const ret = (await fetch(url, fetchOpts)) as FetchResponse;
376
+ const ret = await fetch(url, fetchOpts);
322
377
 
323
- if (!ret.ok) {
324
- ret.responseText = await this.safeResponseTextAsync(ret);
325
- throw Exception.fetchError(opts, ret);
326
- }
378
+ if (!ret.ok) throw Exception.fetchError(opts, ret, await this.safeResponseTextAsync(ret));
327
379
 
328
380
  return ret;
329
381
  }
330
382
 
331
- private async sendJsonInternalAsync(opts: FetchOptions) {
332
- return this.fetchJson({
333
- ...opts,
334
- body: JSON.stringify(opts.body),
335
- headers: {
336
- 'Content-Type': 'application/json',
337
- ...opts.headers
338
- }
383
+ private async parseJsonAsync(opts: FetchOptions, r: Response): Promise<any> {
384
+ if (this.NO_JSON_RESPONSES.includes(r.status)) return null;
385
+ return r.json().catchWhen('SyntaxError', e => {
386
+ throw Exception.fetchJsonParseError(opts, e);
339
387
  });
340
388
  }
341
389
 
@@ -354,6 +402,15 @@ export class FetchService extends HoistService {
354
402
  };
355
403
  }
356
404
 
405
+ /** Headers to be applied to all requests. Specified as object, or dynamic function to create. */
406
+ export type DefaultHeaders = PlainObject | ((opts: FetchOptions) => Awaitable<PlainObject>);
407
+
408
+ /** Handlers to be executed before fufilling or rejecting any exception to caller. */
409
+ export interface FetchInterceptor {
410
+ onFulfilled: (opts: FetchOptions, value: any) => Promise<any>;
411
+ onRejected: (opts: FetchOptions, cause: unknown) => Promise<any>;
412
+ }
413
+
357
414
  /**
358
415
  * Standard options to pass through to fetch, with some additions.
359
416
  * See MDN for available options - {@link https://developer.mozilla.org/en-US/docs/Web/API/Request}.
@@ -420,4 +477,15 @@ export interface FetchOptions {
420
477
  * aborted in favor of the new request.
421
478
  */
422
479
  autoAbortKey?: string;
480
+
481
+ /**
482
+ * True to decode the HTTP response as JSON. Default false.
483
+ */
484
+ asJson?: boolean;
485
+
486
+ /**
487
+ * If set, the request will be tracked via Hoist activity tracking. (Do not set `correlationId`
488
+ * here - use the top-level `correlationId` property instead.)
489
+ */
490
+ track?: string | TrackOptions;
423
491
  }
@@ -7,7 +7,7 @@
7
7
  import {HoistService, TrackOptions, XH} from '@xh/hoist/core';
8
8
  import {isOmitted} from '@xh/hoist/utils/impl';
9
9
  import {stripTags, withDefault} from '@xh/hoist/utils/js';
10
- import {isString} from 'lodash';
10
+ import {isNil, isString} from 'lodash';
11
11
 
12
12
  /**
13
13
  * Primary service for tracking any activity that an application's admins want to track.
@@ -104,7 +104,9 @@ export class TrackService extends HoistService {
104
104
  }
105
105
 
106
106
  const elapsedStr = query.elapsed != null ? `${query.elapsed}ms` : null,
107
- consoleMsgs = [query.category, query.msg, elapsedStr].filter(it => it != null);
107
+ consoleMsgs = [query.category, query.msg, query.correlationId, elapsedStr].filter(
108
+ it => !isNil(it)
109
+ );
108
110
 
109
111
  this.logInfo(...consoleMsgs);
110
112