Cache redesign part 1, and add support for alternative links

This commit is contained in:
Art Lowel
2020-12-11 14:18:44 +01:00
parent f4853972cc
commit 4e18fa35ca
522 changed files with 7537 additions and 6933 deletions

View File

@@ -1,20 +1,33 @@
import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util';
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
race as observableRace
} from 'rxjs';
import { map, switchMap, filter, distinctUntilKeyChanged } from 'rxjs/operators';
import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../data/paginated-list';
import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../data/paginated-list.model';
import { RemoteData } from '../../data/remote-data';
import { RemoteDataError } from '../../data/remote-data-error';
import { RequestEntry } from '../../data/request.reducer';
import {
RequestEntry,
ResponseState,
RequestEntryState,
hasSucceeded
} from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import { filterSuccessfulResponses, getRequestFromRequestHref, getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators';
import { PageInfo } from '../../shared/page-info.model';
import { CacheableObject } from '../object-cache.reducer';
import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/operators';
import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response.models';
import { LinkService } from './link.service';
import { HALLink } from '../../shared/hal-link.model';
import { GenericConstructor } from '../../shared/generic-constructor';
import { getClassForType } from './build-decorators';
import { HALResource } from '../../shared/hal-resource.model';
import { PAGINATED_LIST } from '../../data/paginated-list.resource-type';
import { getUrlWithoutEmbedParams } from '../../index/index.selectors';
import { getResourceTypeValueFor } from '../object-cache.reducer';
@Injectable()
export class RemoteDataBuildService {
@@ -24,15 +37,174 @@ export class RemoteDataBuildService {
}
/**
* Creates a single {@link RemoteData} object based on the response of a request to the REST server, with a list of
* {@link FollowLinkConfig} that indicate which embedded info should be added to the object
* @param href$ Observable href of object we want to retrieve
* Creates an Observable<T> with the payload for a RemoteData object
*
* @param requestEntry$ The {@link RequestEntry} to create a {@link RemoteData} object from
* @param href$ The self link of the object to retrieve. If left empty, the root
* payload link from the response will be used. These links will differ in
* case we're retrieving an object that was embedded in the request for
* another
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s
* should be automatically resolved
* @private
*/
private buildPayload<T>(requestEntry$: Observable<RequestEntry>, href$?: Observable<string>, ...linksToFollow: Array<FollowLinkConfig<any>>): Observable<T> {
if (hasNoValue(href$)) {
href$ = observableOf(undefined);
}
return observableCombineLatest([href$, requestEntry$]).pipe(
switchMap(([href, entry]: [string, RequestEntry]) => {
const hasExactMatchInObjectCache = this.hasExactMatchInObjectCache(href, entry);
if (hasValue(entry.response) &&
(hasExactMatchInObjectCache || this.isCacheablePayload(entry) || this.isUnCacheablePayload(entry))) {
if (hasExactMatchInObjectCache) {
return this.objectCache.getObjectByHref(href);
} else if (this.isCacheablePayload(entry)) {
return this.objectCache.getObjectByHref(entry.response.payloadLink.href);
} else {
return [this.plainObjectToInstance<T>(entry.response.unCacheableObject)];
}
} else if (hasSucceeded(entry.state)) {
return [null];
} else {
return [undefined];
}
}),
switchMap((obj: T) => {
if (hasValue(obj)) {
if (getResourceTypeValueFor((obj as any).type) === PAGINATED_LIST.value) {
return this.buildPaginatedList<T>(obj, ...linksToFollow);
} else if (isNotEmpty(linksToFollow)) {
return [this.linkService.resolveLinks(obj, ...linksToFollow)];
}
}
return [obj];
})
);
}
/**
* When an object is returned from the store, it's possibly a plain javascript object (in case
* it was first instantiated on the server). This method will turn it in to an instance of the
* class corresponding with its type property. If it doesn't have one, or we can't find a
* constructor for that type, it will remain a plain object.
*
* @param obj The object to turn in to a class instance based on its type property
*/
private plainObjectToInstance<T>(obj: any): T {
const type: GenericConstructor<T> = getClassForType(obj.type);
if (typeof type === 'function') {
return Object.assign(new type(), obj) as T
} else {
return Object.assign({}, obj) as T;
}
};
/**
* Returns true if there is a match for the given self link and request entry in the object cache,
* false otherwise. The goal is to find objects that were not the root object of the request, but
* embedded.
*
* @param href the self link to check
* @param entry the request entry the object has to match
* @private
*/
private hasExactMatchInObjectCache(href: string, entry: RequestEntry): boolean {
return hasValue(entry) && hasValue(entry.request) && isNotEmpty(entry.request.uuid) &&
hasValue(href) && this.objectCache.hasByHref(href, entry.request.uuid);
}
/**
* Returns true if the given entry has a valid payloadLink, false otherwise
* @param entry the RequestEntry to check
* @private
*/
private isCacheablePayload(entry: RequestEntry): boolean {
return hasValue(entry.response.payloadLink) && isNotEmpty(entry.response.payloadLink.href);
}
/**
* Returns true if the given entry has an unCacheableObject, false otherwise
* @param entry the RequestEntry to check
* @private
*/
private isUnCacheablePayload(entry: RequestEntry): boolean {
return hasValue(entry.response.unCacheableObject);
}
/**
* Build a PaginatedList by creating a new PaginatedList instance from the given object, to ensure
* it has the correct prototype (you can't be sure if it came from the ngrx store), by
* retrieving the objects in the list and following any links.
*
* @param object A plain object to be turned in to a {@link PaginatedList}
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
private buildPaginatedList<T>(object: any, ...linksToFollow: Array<FollowLinkConfig<any>>): Observable<T> {
const pageLink = linksToFollow.find((linkToFollow: FollowLinkConfig<any>) => linkToFollow.name === 'page');
const otherLinks = linksToFollow.filter((linkToFollow: FollowLinkConfig<any>) => linkToFollow.name !== 'page');
const paginatedList = Object.assign(new PaginatedList(), object);
if (hasValue(pageLink)) {
if (isEmpty(paginatedList.page)) {
const pageSelfLinks = paginatedList._links.page.map((link: HALLink) => link.href);
return this.objectCache.getList(pageSelfLinks).pipe(map((page: any[]) => {
paginatedList.page = page
.map((obj: any) => this.plainObjectToInstance<T>(obj))
.map((obj: any) =>
this.linkService.resolveLinks(obj, ...pageLink.linksToFollow)
);
if (isNotEmpty(otherLinks)) {
return this.linkService.resolveLinks(paginatedList, ...otherLinks);
}
return paginatedList;
}));
} else {
// in case the elements of the paginated list were already filled in, because they're UnCacheableObjects
paginatedList.page = paginatedList.page
.map((obj: any) => this.plainObjectToInstance<T>(obj))
.map((obj: any) =>
this.linkService.resolveLinks(obj, ...pageLink.linksToFollow)
);
if (isNotEmpty(otherLinks)) {
return observableOf(this.linkService.resolveLinks(paginatedList, ...otherLinks));
}
}
}
return observableOf(paginatedList as any);
}
/**
* Creates a {@link RemoteData} object for a rest request and its response
*
* @param requestUUID$ The UUID of the request we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
buildSingle<T extends CacheableObject>(href$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<T>> {
buildFromRequestUUID<T>(requestUUID$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<any>>): Observable<RemoteData<T>> {
if (typeof requestUUID$ === 'string') {
requestUUID$ = observableOf(requestUUID$);
}
const requestEntry$ = requestUUID$.pipe(getRequestFromRequestUUID(this.requestService));
const payload$ = this.buildPayload<T>(requestEntry$, undefined, ...linksToFollow);
return this.toRemoteDataObservable<T>(requestEntry$, payload$);
}
/**
* Creates a {@link RemoteData} object for a rest request and its response
*
* @param href$ self link of object we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
buildFromHref<T>(href$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<any>>): Observable<RemoteData<T>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
href$ = href$.pipe(map((href: string) => getUrlWithoutEmbedParams(href)));
const requestUUID$ = href$.pipe(
switchMap((href: string) =>
this.objectCache.getRequestUUIDBySelfLink(href)),
@@ -41,205 +213,143 @@ export class RemoteDataBuildService {
const requestEntry$ = observableRace(
href$.pipe(getRequestFromRequestHref(this.requestService)),
requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)),
).pipe(
distinctUntilKeyChanged('lastUpdated')
);
// always use self link if that is cached, only if it isn't, get it via the response.
const payload$ =
observableCombineLatest(
href$.pipe(
switchMap((href: string) => this.objectCache.getObjectBySelfLink<T>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getObjectBySelfLink<T>(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}
}),
distinctUntilChanged(),
startWith(undefined)
)
).pipe(
map(([fromSelfLink, fromResponse]) => {
if (hasValue(fromSelfLink)) {
return fromSelfLink;
} else {
return fromResponse;
}
}),
hasValueOperator(),
map((obj: T) =>
this.linkService.resolveLinks(obj, ...linksToFollow)
),
startWith(undefined),
distinctUntilChanged()
);
return this.toRemoteDataObservable(requestEntry$, payload$);
const payload$ = this.buildPayload<T>(requestEntry$, href$, ...linksToFollow);
return this.toRemoteDataObservable<T>(requestEntry$, payload$);
}
/**
* Creates a single {@link RemoteData} object based on the response of a request to the REST server, with a list of
* {@link FollowLinkConfig} that indicate which embedded info should be added to the object
* @param href$ Observable href of object we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
buildSingle<T>(href$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<any>>): Observable<RemoteData<T>> {
return this.buildFromHref(href$, ...linksToFollow);
}
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
const response = reqEntry ? reqEntry.response : undefined;
if (hasValue(response)) {
isSuccessful = response.statusCode === 204 ||
response.statusCode >= 200 && response.statusCode < 300 && hasValue(payload);
const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) {
error = new RemoteDataError(
response.statusCode,
response.statusText,
errorMessage
);
}
return observableCombineLatest([
requestEntry$,
payload$
]).pipe(
filter(([entry,payload]: [RequestEntry, T]) =>
hasValue(entry) &&
// filter out cases where the state is successful, but the payload isn't yet set
!(hasSucceeded(entry.state) && isUndefined(payload))
),
map(([entry, payload]: [RequestEntry, T]) => {
let response = entry.response;
if (hasNoValue(response)) {
response = {} as ResponseState;
}
return new RemoteData(
requestPending,
responsePending,
isSuccessful,
error,
payload,
hasValue(response) ? response.statusCode : undefined
);
return new RemoteData(
response.timeCompleted,
entry.request.responseMsToLive,
entry.lastUpdated,
entry.state,
response.errorMessage,
payload,
response.statusCode
)
})
);
}
/**
* Creates a list of {@link RemoteData} objects based on the response of a request to the REST server, with a list of
*
* Note: T extends HALResource not CacheableObject, because a PaginatedList is a CacheableObject in and of itself
*
* {@link FollowLinkConfig} that indicate which embedded info should be added to the objects
* @param href$ Observable href of objects we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
buildList<T extends CacheableObject>(href$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const tDomainList$ = requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs).pipe(
map((objs: T[]) => {
return objs.map((obj: T) =>
this.linkService.resolveLinks(obj, ...linksToFollow)
);
}));
}),
startWith([]),
distinctUntilChanged(),
);
const pageInfo$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: DSOSuccessResponse) => {
if (hasValue(response.pageInfo)) {
return Object.assign(new PageInfo(), response.pageInfo);
}
})
);
const payload$ = observableCombineLatest([tDomainList$, pageInfo$]).pipe(
map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList);
})
);
return this.toRemoteDataObservable(requestEntry$, payload$);
buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', undefined, false, ...linksToFollow));
}
/**
* Turns an array of RemoteData observables in to an observable RemoteData[]
*
* By doing this you lose most of the info about the status of the original
* RemoteData objects, as you have to squash them down in to one. So use
* this only if the list you need isn't available on the REST API. If you
* need to use it, it's likely an indication that a REST endpoint is missing
*
* @param input the array of RemoteData observables to start from
*/
aggregate<T>(input: Array<Observable<RemoteData<T>>>): Observable<RemoteData<T[]>> {
if (isEmpty(input)) {
return createSuccessfulRemoteDataObject$([]);
return createSuccessfulRemoteDataObject$([], new Date().getTime());
}
return observableCombineLatest(...input).pipe(
return observableCombineLatest(input).pipe(
map((arr) => {
// The request of an aggregate RD should be pending if at least one
// of the RDs it's based on is still in the state RequestPending
const requestPending: boolean = arr
.map((d: RemoteData<T>) => d.isRequestPending)
.find((b: boolean) => b === true);
const timeCompleted = arr
.map((d: RemoteData<T>) => d.timeCompleted)
.reduce((max: number, current: number) => current > max ? current : max)
// The response of an aggregate RD should be pending if no requests
// are still pending and at least one of the RDs it's based
// on is still in the state ResponsePending
const responsePending: boolean = !requestPending && arr
.map((d: RemoteData<T>) => d.isResponsePending)
.find((b: boolean) => b === true);
const msToLive = arr
.map((d: RemoteData<T>) => d.msToLive)
.reduce((min: number, current: number) => current < min ? current : min)
let isSuccessful: boolean;
// isSuccessful should be undefined until all responses have come in.
// We can't know its state beforehand. We also can't say it's false
// because that would imply a request failed.
if (!(requestPending || responsePending)) {
isSuccessful = arr
.map((d: RemoteData<T>) => d.hasSucceeded)
.every((b: boolean) => b === true);
const lastUpdated = arr
.map((d: RemoteData<T>) => d.lastUpdated)
.reduce((max: number, current: number) => current > max ? current : max)
let state: RequestEntryState;
if (arr.some((d: RemoteData<T>) => d.isRequestPending)) {
state = RequestEntryState.RequestPending;
} else if (arr.some((d: RemoteData<T>) => d.isResponsePending)) {
state = RequestEntryState.ResponsePending;
} else if (arr.some((d: RemoteData<T>) => d.isErrorStale)) {
state = RequestEntryState.ErrorStale;
} else if (arr.some((d: RemoteData<T>) => d.isError)) {
state = RequestEntryState.Error;
} else if (arr.some((d: RemoteData<T>) => d.isSuccessStale)) {
state = RequestEntryState.SuccessStale;
} else {
state = RequestEntryState.Success;
}
const errorMessage: string = arr
.map((d: RemoteData<T>) => d.error)
.map((e: RemoteDataError, idx: number) => {
.map((d: RemoteData<T>) => d.errorMessage)
.map((e: string, idx: number) => {
if (hasValue(e)) {
return `[${idx}]: ${e.message}`;
return `[${idx}]: ${e}`;
}
}).filter((e: string) => hasValue(e))
.join(', ');
const statusText: string = arr
.map((d: RemoteData<T>) => d.error)
.map((e: RemoteDataError, idx: number) => {
if (hasValue(e)) {
return `[${idx}]: ${e.statusText}`;
}
}).filter((c: string) => hasValue(c))
.join(', ');
const statusCodes = new Set(arr
.map((d: RemoteData<T>) => d.statusCode));
const statusCode: number = arr
.map((d: RemoteData<T>) => d.error)
.map((e: RemoteDataError, idx: number) => {
if (hasValue(e)) {
return e.statusCode;
}
}).filter((c: number) => hasValue(c))
.reduce((acc, status) => status, undefined);
let statusCode: number;
const error = new RemoteDataError(statusCode, statusText, errorMessage);
if (statusCodes.size === 1) {
statusCode = statusCodes.values().next().value;
} else if (statusCodes.size > 1) {
statusCode = 207;
}
const payload: T[] = arr.map((d: RemoteData<T>) => d.payload);
return new RemoteData(
requestPending,
responsePending,
isSuccessful,
error,
payload
timeCompleted,
msToLive,
lastUpdated,
state,
errorMessage,
payload,
statusCode
);
}))
}
private toPaginatedList<T>(input: Observable<RemoteData<T[] | PaginatedList<T>>>, pageInfo: PageInfo): Observable<RemoteData<PaginatedList<T>>> {
return input.pipe(
map((rd: RemoteData<T[] | PaginatedList<T>>) => {
const rdAny = rd as any;
const newRD = new RemoteData(rdAny.requestPending, rdAny.responsePending, rdAny.isSuccessful, rd.error, undefined);
if (Array.isArray(rd.payload)) {
return Object.assign(newRD, { payload: new PaginatedList(pageInfo, rd.payload) })
} else if (isNotUndefined(rd.payload)) {
return Object.assign(newRD, { payload: new PaginatedList(pageInfo, rd.payload.page) });
} else {
return Object.assign(newRD, { payload: new PaginatedList(pageInfo, []) });
}
})
);
}
}