Files
dspace-angular/src/app/core/cache/builders/remote-data-build.service.ts
2021-01-20 17:43:40 +01:00

356 lines
15 KiB
TypeScript

import { Injectable } from '@angular/core';
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, followLink } from '../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../data/paginated-list.model';
import { RemoteData } from '../../data/remote-data';
import {
RequestEntry,
ResponseState,
RequestEntryState,
hasSucceeded
} from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/operators';
import { ObjectCacheService } from '../object-cache.service';
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 {
constructor(protected objectCache: ObjectCacheService,
protected linkService: LinkService,
protected requestService: RequestService) {
}
/**
* 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: 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: 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
*/
buildFromRequestUUID<T>(requestUUID$: string | Observable<string>, ...linksToFollow: 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: 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)),
);
const requestEntry$ = observableRace(
href$.pipe(getRequestFromRequestHref(this.requestService)),
requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)),
).pipe(
distinctUntilKeyChanged('lastUpdated')
);
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: FollowLinkConfig<any>[]): Observable<RemoteData<T>> {
return this.buildFromHref(href$, ...linksToFollow);
}
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
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(
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 HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', undefined, false, true, true, ...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: Observable<RemoteData<T>>[]): Observable<RemoteData<T[]>> {
if (isEmpty(input)) {
return createSuccessfulRemoteDataObject$([], new Date().getTime());
}
return observableCombineLatest(input).pipe(
map((arr) => {
const timeCompleted = arr
.map((d: RemoteData<T>) => d.timeCompleted)
.reduce((max: number, current: number) => current > max ? current : max);
const msToLive = arr
.map((d: RemoteData<T>) => d.msToLive)
.reduce((min: number, current: number) => current < min ? current : min);
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.errorMessage)
.map((e: string, idx: number) => {
if (hasValue(e)) {
return `[${idx}]: ${e}`;
}
}).filter((e: string) => hasValue(e))
.join(', ');
const statusCodes = new Set(arr
.map((d: RemoteData<T>) => d.statusCode));
let statusCode: number;
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(
timeCompleted,
msToLive,
lastUpdated,
state,
errorMessage,
payload,
statusCode
);
}));
}
}