-
Notifications
You must be signed in to change notification settings - Fork 437
/
request.service.ts
555 lines (516 loc) · 18.4 KB
/
request.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
createSelector,
MemoizedSelector,
select,
Store,
} from '@ngrx/store';
import cloneDeep from 'lodash/cloneDeep';
import {
asapScheduler,
from as observableFrom,
Observable,
} from 'rxjs';
import {
filter,
find,
map,
mergeMap,
switchMap,
take,
tap,
toArray,
} from 'rxjs/operators';
import {
hasNoValue,
hasValue,
isEmpty,
isNotEmpty,
} from '../../shared/empty.util';
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { coreSelector } from '../core.selectors';
import { CoreState } from '../core-state.model';
import { IndexState } from '../index/index.reducer';
import {
getUrlWithoutEmbedParams,
requestIndexSelector,
} from '../index/index.selectors';
import { UUIDService } from '../shared/uuid.service';
import {
RequestConfigureAction,
RequestExecuteAction,
RequestStaleAction,
} from './request.actions';
import { GetRequest } from './request.models';
import { RequestEntry } from './request-entry.model';
import {
isLoading,
isStale,
} from './request-entry-state.model';
import { RequestState } from './request-state.model';
import { RestRequest } from './rest-request.model';
import { RestRequestMethod } from './rest-request-method';
/**
* The base selector function to select the request state in the store
*/
const requestCacheSelector = createSelector(
coreSelector,
(state: CoreState) => state['data/request'],
);
/**
* Selector function to select a request entry by uuid from the store
* @param uuid The uuid of the request
*/
const entryFromUUIDSelector = (uuid: string): MemoizedSelector<CoreState, RequestEntry> => createSelector(
requestCacheSelector,
(state: RequestState) => {
return hasValue(state) ? state[uuid] : undefined;
},
);
/**
* Selector function to select a request entry by href from the store
* @param href The href of the request
*/
const entryFromHrefSelector = (href: string): MemoizedSelector<CoreState, RequestEntry> => createSelector(
requestIndexSelector,
requestCacheSelector,
(indexState: IndexState, requestState: RequestState) => {
let uuid: any;
if (hasValue(indexState)) {
uuid = indexState[getUrlWithoutEmbedParams(href)];
} else {
return undefined;
}
if (hasValue(requestState)) {
return requestState[uuid];
} else {
return undefined;
}
},
);
/**
* Create a selector that fetches a list of request UUIDs from a given index substate of which the request href
* contains a given substring
* @param selector MemoizedSelector to start from
* @param href Substring that the request's href should contain
*/
const uuidsFromHrefSubstringSelector =
(selector: MemoizedSelector<CoreState, IndexState>, href: string): MemoizedSelector<CoreState, string[]> => createSelector(
selector,
(state: IndexState) => getUuidsFromHrefSubstring(state, href),
);
/**
* Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring
* @param state The IndexState
* @param href Substring that the request's href should contain
*/
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
let result = [];
if (isNotEmpty(state)) {
result = Object.keys(state).filter((key) => key.includes(href)).map((key) => state[key]);
}
return result;
};
/**
* Check whether a cached entry exists and isn't stale
*
* @param entry
* the entry to check
* @return boolean
* false if the entry has no value, or its time to live has exceeded,
* true otherwise
*/
const isValid = (entry: RequestEntry): boolean => {
if (hasNoValue(entry)) {
// undefined entries are invalid
return false;
} else {
if (isLoading(entry.state)) {
// entries that are still loading are always valid
return true;
} else {
if (isStale(entry.state)) {
// entries that are stale are always invalid
return false;
} else {
// check whether it should be stale
const timeOutdated = entry.response.timeCompleted + entry.request.responseMsToLive;
const now = new Date().getTime();
const isOutDated = now > timeOutdated;
return !isOutDated;
}
}
}
};
/**
* A service to interact with the request state in the store
*/
@Injectable({
providedIn: 'root',
})
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>) {
}
generateRequestId(): string {
return `client/${this.uuidService.generate()}`;
}
/**
* Check if a GET request is currently pending
*/
isPending(request: RestRequest): boolean {
// If the request is not a GET request, it will never be considered pending, because you may
// want to execute the exact same e.g. POST multiple times
if (request.method !== RestRequestMethod.GET) {
return false;
}
// check requests that haven't made it to the store yet
if (this.requestsOnTheirWayToTheStore.includes(request.href)) {
return true;
}
// then check the store
let isPending = false;
this.getByHref(request.href).pipe(
take(1))
.subscribe((re: RequestEntry) => {
isPending = (hasValue(re) && isLoading(re.state) && !isStale(re.state));
});
return isPending;
}
/**
* Retrieve a RequestEntry based on their uuid
*/
getByUUID(uuid: string): Observable<RequestEntry> {
return this.store.pipe(
select(entryFromUUIDSelector(uuid)),
this.fixRequestHeaders(),
this.checkStale(),
);
}
/**
* Operator that turns the request headers back in to an HttpHeaders instance after an entry has
* been retrieved from the ngrx store
* @private
*/
private fixRequestHeaders() {
return (source: Observable<RequestEntry>): Observable<RequestEntry> => {
return source.pipe(map((entry: RequestEntry) => {
// Headers break after being retrieved from the store (because of lazy initialization)
// Combining them with a new object fixes this issue
if (hasValue(entry) && hasValue(entry.request) && hasValue(entry.request.options) && hasValue(entry.request.options.headers)) {
entry = cloneDeep(entry);
entry.request.options.headers = Object.assign(new HttpHeaders(), entry.request.options.headers);
}
return entry;
}),
);
};
}
/**
* Operator that will check if an entry should be stale, and will dispatch an action to set it to
* stale if it should
* @private
*/
private checkStale() {
return (source: Observable<RequestEntry>): Observable<RequestEntry> => {
return source.pipe(
tap((entry: RequestEntry) => {
if (hasValue(entry) && hasValue(entry.request) && !isStale(entry.state) && !isValid(entry)) {
asapScheduler.schedule(() => this.store.dispatch(new RequestStaleAction(entry.request.uuid)));
}
}),
);
};
}
/**
* Retrieve a RequestEntry based on its href
*/
getByHref(href: string): Observable<RequestEntry> {
return this.store.pipe(
select(entryFromHrefSelector(href)),
this.fixRequestHeaders(),
this.checkStale(),
);
}
/**
* Add the given request to the ngrx store, and send it to the rest api
*
* @param request The request to send out
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to false
* @returns true if the request was sent, false otherwise
*/
send(request: RestRequest, useCachedVersionIfAvailable = false): boolean {
if (useCachedVersionIfAvailable && request.method !== RestRequestMethod.GET) {
console.warn(`${JSON.stringify(request, null, 2)} is not a GET request. In general only GET requests should reuse cached data.`);
}
if (this.shouldDispatchRequest(request, useCachedVersionIfAvailable)) {
this.dispatchRequest(request);
if (request.method === RestRequestMethod.GET) {
this.trackRequestsOnTheirWayToTheStore(request as GetRequest);
}
return true;
} else {
return false;
}
}
/**
* Convert request Payload to a URL-encoded string
*
* e.g. uriEncodeBody({param: value, param1: value1, param2: [value3, value4]})
* returns: param=value¶m1=value1¶m2=value3¶m2=value4
*
* @param body
* The request Payload to convert
* @return string
* URL-encoded string
*/
public uriEncodeBody(body: any) {
let queryParams = '';
if (isNotEmpty(body) && typeof body === 'object') {
Object.keys(body)
.forEach((param: string) => {
const encodedParam = encodeURIComponent(param);
if (Array.isArray(body[param])) {
for (const element of body[param]) {
const encodedBody = encodeURIComponent(element);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
}
} else {
const encodedBody = encodeURIComponent(body[param]);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
}
});
}
return queryParams;
}
/**
* Set all requests that match (part of) the href to stale
*
* @param href A substring of the request(s) href
* @return Returns an observable emitting whether or not the cache is removed
* @deprecated use setStaleByHrefSubstring instead
*/
removeByHrefSubstring(href: string): Observable<boolean> {
return this.setStaleByHrefSubstring(href);
}
/**
* Set all requests that match (part of) the href to stale
*
* @param href A substring of the request(s) href
* @return Returns an observable emitting when those requests are all stale
*/
setStaleByHrefSubstring(href: string): Observable<boolean> {
const requestUUIDs$ = this.store.pipe(
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
take(1),
);
requestUUIDs$.subscribe((uuids: string[]) => {
for (const uuid of uuids) {
this.store.dispatch(new RequestStaleAction(uuid));
}
});
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0);
// emit true after all requests are stale
return requestUUIDs$.pipe(
switchMap((uuids: string[]) => {
if (isEmpty(uuids)) {
// if there were no matching requests, emit true immediately
return [true];
} else {
// otherwise emit all request uuids in order
return observableFrom(uuids).pipe(
// retrieve the RequestEntry for each uuid
mergeMap((uuid: string) => this.getByUUID(uuid)),
// check whether it is undefined or stale
map((request: RequestEntry) => hasNoValue(request) || isStale(request.state)),
// if it is, complete
find((stale: boolean) => stale === true),
// after all observables above are completed, emit them as a single array
toArray(),
// when the array comes in, emit true
map(() => true),
);
}
}),
);
}
/**
* Mark a request as stale
* @param uuid the UUID of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByUUID(uuid: string): Observable<boolean> {
this.store.dispatch(new RequestStaleAction(uuid));
return this.getByUUID(uuid).pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1),
);
}
/**
* Mark a request as stale
* @param href the href of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByHref(href: string): Observable<boolean> {
const requestEntry$ = this.getByHref(href);
requestEntry$.pipe(
filter((re: RequestEntry) => isNotEmpty(re)),
map((re: RequestEntry) => re.request.uuid),
take(1),
).subscribe((uuid: string) => {
this.store.dispatch(new RequestStaleAction(uuid));
});
return requestEntry$.pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1),
);
}
/**
* Check if a GET request is in the cache or if it's still pending
* @param {GetRequest} request The request to check
* @param {boolean} useCachedVersionIfAvailable Whether or not to allow the use of a cached version
* @returns {boolean} True if the request is cached or still pending
*/
public shouldDispatchRequest(request: RestRequest, useCachedVersionIfAvailable: boolean): boolean {
// if it's not a GET request
if (request.method !== RestRequestMethod.GET) {
return true;
// if it is a GET request, check it isn't pending
} else if (this.isPending(request)) {
return false;
// if it is pending, check if we're allowed to use a cached version
} else if (!useCachedVersionIfAvailable) {
return true;
} else {
// if we are, check the request cache
const urlWithoutEmbedParams = getUrlWithoutEmbedParams(request.href);
if (this.hasByHref(urlWithoutEmbedParams) === true) {
return false;
} else {
// if it isn't in the request cache, check the object cache
let inObjCache = false;
this.objectCache.getByHref(urlWithoutEmbedParams)
.subscribe((entry: ObjectCacheEntry) => {
// if the object cache has a match, check if the request that the object came with is
// still valid
inObjCache = this.hasByUUID(entry.requestUUIDs[0]);
}).unsubscribe();
// we should send the request if it isn't cached
return !inObjCache;
}
}
}
/**
* Configure and execute the request
* @param {RestRequest} request to dispatch
*/
private dispatchRequest(request: RestRequest) {
asapScheduler.schedule(() => {
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(request.uuid));
});
}
/**
* ngrx action dispatches are asynchronous. But this.isPending needs to return true as soon as the
* send method for a GET request has been executed, otherwise certain requests will happen multiple times.
*
* This method will store the href of every GET request that gets configured in a local variable, and
* remove it as soon as it can be found in the store.
*/
private trackRequestsOnTheirWayToTheStore(request: GetRequest) {
this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href];
this.getByHref(request.href).pipe(
filter((re: RequestEntry) => hasValue(re) && hasValue(re.request) && re.request.uuid === request.uuid),
take(1),
).subscribe((re: RequestEntry) => {
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href);
});
}
/**
* Dispatch commit action to send all changes (for a certain method) to the server (buffer)
* @param {RestRequestMethod} method RestRequestMethod for which the changes should be committed
*/
commit(method?: RestRequestMethod) {
this.store.dispatch(new CommitSSBAction(method));
}
/**
* Check whether the request with the specified href is cached
*
* @param href
* The link of the request to check
* @param checkValidity
* Whether or not to check the validity of an entry if one is found
* @return boolean
* true if a request with the specified href is cached and is valid (if checkValidity is true)
* false otherwise
*/
hasByHref(href: string, checkValidity = true): boolean {
let result = false;
/* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/
this.hasByHref$(href, checkValidity)
.subscribe((hasByHref: boolean) => result = hasByHref)
.unsubscribe();
return result;
}
/**
* Check whether the request with the specified href is cached
*
* @param href
* The href of the request to check
* @param checkValidity
* Whether or not to check the validity of an entry if one is found
* @return Observable<boolean>
* true if a request with the specified href is cached and is valid (if checkValidity is true)
* false otherwise
*/
hasByHref$(href: string, checkValidity = true): Observable<boolean> {
return this.getByHref(href).pipe(
map((requestEntry: RequestEntry) => checkValidity ? isValid(requestEntry) : hasValue(requestEntry)),
);
}
/**
* Check whether the request with the specified uuid is cached
*
* @param uuid
* The link of the request to check
* @param checkValidity
* Whether or not to check the validity of an entry if one is found
* @return boolean
* true if a request with the specified uuid is cached and is valid (if checkValidity is true)
* false otherwise
*/
hasByUUID(uuid: string, checkValidity = true): boolean {
let result = false;
/* NB: that this is only a solution because the select method is synchronous, see: https://github.com/ngrx/store/issues/296#issuecomment-269032571*/
this.hasByUUID$(uuid, checkValidity)
.subscribe((hasByUUID: boolean) => result = hasByUUID)
.unsubscribe();
return result;
}
/**
* Check whether the request with the specified uuid is cached
*
* @param uuid
* The uuid of the request to check
* @param checkValidity
* Whether or not to check the validity of an entry if one is found
* @return Observable<boolean>
* true if a request with the specified uuid is cached and is valid (if checkValidity is true)
* false otherwise
*/
hasByUUID$(uuid: string, checkValidity = true): Observable<boolean> {
return this.getByUUID(uuid).pipe(
map((requestEntry: RequestEntry) => checkValidity ? isValid(requestEntry) : hasValue(requestEntry)),
);
}
}