diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 727eef41d4..1089eff555 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -8,12 +8,12 @@ import { ResponseCacheService } from '../response-cache.service'; import { RequestEntry } from '../../data/request.reducer'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { ResponseCacheEntry } from '../response-cache.reducer'; -import { ErrorResponse, SuccessResponse } from '../response-cache.models'; +import { ErrorResponse, DSOSuccessResponse } from '../response-cache.models'; import { RemoteData } from '../../data/remote-data'; import { GenericConstructor } from '../../shared/generic-constructor'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; import { NormalizedObjectFactory } from '../models/normalized-object-factory'; -import { Request } from '../../data/request.models'; +import { RestRequest } from '../../data/request.models'; @Injectable() export class RemoteDataBuildService { @@ -25,19 +25,26 @@ export class RemoteDataBuildService { } buildSingle( - href: string, + hrefObs: string | Observable, normalizedType: GenericConstructor ): RemoteData { - const requestHrefObs = this.objectCache.getRequestHrefBySelfLink(href); + if (typeof hrefObs === 'string') { + hrefObs = Observable.of(hrefObs); + } + + const requestHrefObs = hrefObs.flatMap((href: string) => + this.objectCache.getRequestHrefBySelfLink(href)); const requestObs = Observable.race( - this.requestService.get(href).filter((entry) => hasValue(entry)), + hrefObs.flatMap((href: string) => this.requestService.get(href)) + .filter((entry) => hasValue(entry)), requestHrefObs.flatMap((requestHref) => this.requestService.get(requestHref)).filter((entry) => hasValue(entry)) ); const responseCacheObs = Observable.race( - this.responseCache.get(href).filter((entry) => hasValue(entry)), + hrefObs.flatMap((href: string) => this.responseCache.get(href)) + .filter((entry) => hasValue(entry)), requestHrefObs.flatMap((requestHref) => this.responseCache.get(requestHref)).filter((entry) => hasValue(entry)) ); @@ -60,17 +67,18 @@ export class RemoteDataBuildService { /* tslint:disable:no-string-literal */ const pageInfo = responseCacheObs .filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo'])) - .map((entry: ResponseCacheEntry) => (entry.response as SuccessResponse).pageInfo) + .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).pageInfo) .distinctUntilChanged(); /* tslint:enable:no-string-literal */ // always use self link if that is cached, only if it isn't, get it via the response. const payload = Observable.combineLatest( - this.objectCache.getBySelfLink(href, normalizedType).startWith(undefined), + hrefObs.flatMap((href: string) => this.objectCache.getBySelfLink(href, normalizedType)) + .startWith(undefined), responseCacheObs .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as SuccessResponse).resourceSelfLinks) + .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) .flatMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { return this.objectCache.getBySelfLink(resourceSelfLinks[0], normalizedType); @@ -93,7 +101,7 @@ export class RemoteDataBuildService { }).distinctUntilChanged(); return new RemoteData( - href, + hrefObs, requestPending, responsePending, isSuccessFul, @@ -105,12 +113,17 @@ export class RemoteDataBuildService { } buildList( - href: string, + hrefObs: string | Observable, normalizedType: GenericConstructor ): RemoteData { - const requestObs = this.requestService.get(href) + if (typeof hrefObs === 'string') { + hrefObs = Observable.of(hrefObs); + } + + const requestObs = hrefObs.flatMap((href: string) => this.requestService.get(href)) + .filter((entry) => hasValue(entry)); + const responseCacheObs = hrefObs.flatMap((href: string) => this.responseCache.get(href)) .filter((entry) => hasValue(entry)); - const responseCacheObs = this.responseCache.get(href).filter((entry) => hasValue(entry)); const requestPending = requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged(); @@ -131,13 +144,13 @@ export class RemoteDataBuildService { /* tslint:disable:no-string-literal */ const pageInfo = responseCacheObs .filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo'])) - .map((entry: ResponseCacheEntry) => (entry.response as SuccessResponse).pageInfo) + .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).pageInfo) .distinctUntilChanged(); /* tslint:enable:no-string-literal */ const payload = responseCacheObs .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as SuccessResponse).resourceSelfLinks) + .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) .flatMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs, normalizedType) .map((normList: TNormalized[]) => { @@ -149,7 +162,7 @@ export class RemoteDataBuildService { .distinctUntilChanged(); return new RemoteData( - href, + hrefObs, requestPending, responsePending, isSuccessFul, @@ -174,7 +187,7 @@ export class RemoteDataBuildService { // are dispatched, but sometimes don't arrive. I'm unsure why atm. setTimeout(() => { normalized[relationship].forEach((href: string) => { - this.requestService.configure(new Request(href)) + this.requestService.configure(new RestRequest(href)) }); }, 0); @@ -192,7 +205,7 @@ export class RemoteDataBuildService { // without the setTimeout, the actions inside requestService.configure // are dispatched, but sometimes don't arrive. I'm unsure why atm. setTimeout(() => { - this.requestService.configure(new Request(normalized[relationship])); + this.requestService.configure(new RestRequest(normalized[relationship])); }, 0); // The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams) @@ -259,7 +272,7 @@ export class RemoteDataBuildService { // This is an aggregated object, it doesn't necessarily correspond // to a single REST endpoint, so instead of a self link, use the // current time in ms for a somewhat unique id - `${new Date().getTime()}`, + Observable.of(`${new Date().getTime()}`), requestPending, responsePending, isSuccessFul, diff --git a/src/app/core/cache/response-cache.actions.ts b/src/app/core/cache/response-cache.actions.ts index 426996b4ac..0389067690 100644 --- a/src/app/core/cache/response-cache.actions.ts +++ b/src/app/core/cache/response-cache.actions.ts @@ -1,7 +1,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; -import { Response } from './response-cache.models'; +import { RestResponse } from './response-cache.models'; /** * The list of ResponseCacheAction type definitions @@ -17,12 +17,12 @@ export class ResponseCacheAddAction implements Action { type = ResponseCacheActionTypes.ADD; payload: { key: string, - response: Response + response: RestResponse timeAdded: number; msToLive: number; }; - constructor(key: string, response: Response, timeAdded: number, msToLive: number) { + constructor(key: string, response: RestResponse, timeAdded: number, msToLive: number) { this.payload = { key, response, timeAdded, msToLive }; } } diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index a860d682bf..d70d4822bb 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -2,14 +2,14 @@ import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; /* tslint:disable:max-classes-per-file */ -export class Response { +export class RestResponse { constructor( public isSuccessful: boolean, public statusCode: string ) { } } -export class SuccessResponse extends Response { +export class DSOSuccessResponse extends RestResponse { constructor( public resourceSelfLinks: string[], public statusCode: string, @@ -19,7 +19,16 @@ export class SuccessResponse extends Response { } } -export class ErrorResponse extends Response { +export class RootSuccessResponse extends RestResponse { + constructor( + public endpointMap: { [linkName: string]: string }, + public statusCode: string, + ) { + super(true, statusCode); + } +} + +export class ErrorResponse extends RestResponse { errorMessage: string; constructor(error: RequestError) { diff --git a/src/app/core/cache/response-cache.reducer.ts b/src/app/core/cache/response-cache.reducer.ts index b6a9d903b4..73c680c1f5 100644 --- a/src/app/core/cache/response-cache.reducer.ts +++ b/src/app/core/cache/response-cache.reducer.ts @@ -5,14 +5,14 @@ import { } from './response-cache.actions'; import { CacheEntry } from './cache-entry'; import { hasValue } from '../../shared/empty.util'; -import { Response } from './response-cache.models'; +import { RestResponse } from './response-cache.models'; /** * An entry in the ResponseCache */ export class ResponseCacheEntry implements CacheEntry { key: string; - response: Response; + response: RestResponse; timeAdded: number; msToLive: number; } diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts index ed1291e67a..eac76c519e 100644 --- a/src/app/core/cache/response-cache.service.ts +++ b/src/app/core/cache/response-cache.service.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs/Observable'; import { ResponseCacheEntry } from './response-cache.reducer'; import { hasNoValue } from '../../shared/empty.util'; import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions'; -import { Response } from './response-cache.models'; +import { RestResponse } from './response-cache.models'; import { CoreState } from '../core.reducers'; import { keySelector } from '../shared/selectors'; @@ -23,7 +23,7 @@ export class ResponseCacheService { private store: Store ) { } - add(key: string, response: Response, msToLive: number): Observable { + add(key: string, response: RestResponse, msToLive: number): Observable { if (!this.has(key)) { // this.store.dispatch(new ResponseCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions)); this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive)); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 2f98eecb0c..a65be35d5b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -18,6 +18,8 @@ import { coreEffects } from './core.effects'; import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; import { coreReducers } from './core.reducers'; +import { DSOResponseParsingService } from './data/dso-response-parsing.service'; +import { RootResponseParsingService } from './data/root-response-parsing.service'; const IMPORTS = [ CommonModule, @@ -43,7 +45,9 @@ const PROVIDERS = [ PaginationComponentOptions, ResponseCacheService, RequestService, - RemoteDataBuildService + RemoteDataBuildService, + DSOResponseParsingService, + RootResponseParsingService ]; @NgModule({ diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 8e69827184..ec765c3cb1 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -3,7 +3,6 @@ import { Store } from '@ngrx/store'; import { DataService } from './data.service'; import { Collection } from '../shared/collection.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { NormalizedCollection } from '../cache/models/normalized-collection.model'; import { CoreState } from '../core.reducers'; @@ -13,11 +12,10 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Injectable() export class CollectionDataService extends DataService { - protected resourceEndpoint = '/core/collections'; + protected linkName = 'collections'; protected browseEndpoint = '/discover/browses/dateissued/collections'; constructor( - protected objectCache: ObjectCacheService, protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index d749171e1f..532bce5ee6 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -4,7 +4,6 @@ import { Store } from '@ngrx/store'; import { DataService } from './data.service'; import { Community } from '../shared/community.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { CoreState } from '../core.reducers'; @@ -14,11 +13,10 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Injectable() export class CommunityDataService extends DataService { - protected resourceEndpoint = '/core/communities'; + protected linkName = 'communities'; protected browseEndpoint = '/discover/browses/dateissued/communities'; constructor( - protected objectCache: ObjectCacheService, protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 3d7a724709..abdd364667 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,9 +1,11 @@ -import { ObjectCacheService } from '../cache/object-cache.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { CacheableObject } from '../cache/object-cache.reducer'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { RemoteData } from './remote-data'; -import { FindAllOptions, FindAllRequest, FindByIDRequest, Request } from './request.models'; +import { + FindAllOptions, FindAllRequest, FindByIDRequest, RestRequest, + RootEndpointRequest +} from './request.models'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { RequestService } from './request.service'; @@ -12,14 +14,15 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { GlobalConfig } from '../../../config'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { Observable } from 'rxjs/Observable'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { RootSuccessResponse } from '../cache/response-cache.models'; export abstract class DataService { - protected abstract objectCache: ObjectCacheService; protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; protected abstract store: Store; - protected abstract resourceEndpoint: string; + protected abstract linkName: string; protected abstract browseEndpoint: string; constructor( @@ -30,21 +33,23 @@ export abstract class DataService } private getEndpoint(linkName: string): Observable { - const apiUrl = new RESTURLCombiner(this.EnvConfig, '/').toString(); - this.requestService.configure(new Request(apiUrl)); - // TODO fetch from store - return Observable.of(undefined); + const request = new RootEndpointRequest(this.EnvConfig); + this.requestService.configure(request); + return this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .filter((response: RootSuccessResponse) => isNotEmpty(response) && isNotEmpty(response.endpointMap)) + .map((response: RootSuccessResponse) => response.endpointMap[linkName]) } - protected getFindAllHref(options: FindAllOptions = {}): string { + protected getFindAllHref(endpoint, options: FindAllOptions = {}): string { let result; const args = []; if (hasValue(options.scopeID)) { - result = this.browseEndpoint; + result = new RESTURLCombiner(this.EnvConfig, this.browseEndpoint).toString(); args.push(`scope=${options.scopeID}`); } else { - result = this.resourceEndpoint; + result = endpoint; } if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { @@ -67,31 +72,41 @@ export abstract class DataService if (isNotEmpty(args)) { result = `${result}?${args.join('&')}`; } - return new RESTURLCombiner(this.EnvConfig, result).toString(); + return result; } findAll(options: FindAllOptions = {}): RemoteData { - const href = this.getFindAllHref(options); - const request = new FindAllRequest(href, options); - this.requestService.configure(request); - return this.rdbService.buildList(href, this.normalizedResourceType); - // return this.rdbService.buildList(href); + const hrefObs = this.getEndpoint(this.linkName) + .map((endpoint: string) => this.getFindAllHref(endpoint, options)); + + hrefObs + .subscribe((href: string) => { + const request = new FindAllRequest(href, options); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs, this.normalizedResourceType); } - protected getFindByIDHref(resourceID): string { - return new RESTURLCombiner(this.EnvConfig, `${this.resourceEndpoint}/${resourceID}`).toString(); + protected getFindByIDHref(endpoint, resourceID): string { + return `${endpoint}/${resourceID}`; } findById(id: string): RemoteData { - const href = this.getFindByIDHref(id); - const request = new FindByIDRequest(href, id); - this.requestService.configure(request); - return this.rdbService.buildSingle(href, this.normalizedResourceType); - // return this.rdbService.buildSingle(href); + const hrefObs = this.getEndpoint(this.linkName) + .map((endpoint: string) => this.getFindByIDHref(endpoint, id)); + + hrefObs + .subscribe((href: string) => { + const request = new FindByIDRequest(href, id); + this.requestService.configure(request); + }); + + return this.rdbService.buildSingle(hrefObs, this.normalizedResourceType); } findByHref(href: string): RemoteData { - this.requestService.configure(new Request(href)); + this.requestService.configure(new RestRequest(href)); return this.rdbService.buildSingle(href, this.normalizedResourceType); // return this.rdbService.buildSingle(href)); } diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts new file mode 100644 index 0000000000..b7929498f1 --- /dev/null +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -0,0 +1,149 @@ +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Inject, Injectable } from '@angular/core'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { GLOBAL_CONFIG } from '../../../config'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ResourceType } from '../shared/resource-type'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestResponse, DSOSuccessResponse } from '../cache/response-cache.models'; +import { RestRequest } from './request.models'; +import { PageInfo } from '../shared/page-info.model'; +import { ResponseParsingService } from './parsing.service'; + +function isObjectLevel(halObj: any) { + return isNotEmpty(halObj._links) && hasValue(halObj._links.self); +} + +function isPaginatedResponse(halObj: any) { + return isNotEmpty(halObj.page) && hasValue(halObj._embedded); +} + +function flattenSingleKeyObject(obj: any): any { + const keys = Object.keys(obj); + if (keys.length !== 1) { + throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`); + } + return obj[keys[0]]; +} + +/* tslint:disable:max-classes-per-file */ +class ProcessRequestDTO { + [key: string]: NormalizedObject[] +} + +@Injectable() +export class DSOResponseParsingService implements ResponseParsingService { + constructor( + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, + private objectCache: ObjectCacheService, + ) { + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const processRequestDTO = this.process(data.payload, request.href); + const selfLinks = flattenSingleKeyObject(processRequestDTO).map((no) => no.self); + return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload.page)) + } + + protected process(data: any, requestHref: string): ProcessRequestDTO { + + if (isNotEmpty(data)) { + if (isPaginatedResponse(data)) { + return this.process(data._embedded, requestHref); + } else if (isObjectLevel(data)) { + return { topLevel: this.deserializeAndCache(data, requestHref) }; + } else { + const result = new ProcessRequestDTO(); + if (Array.isArray(data)) { + result.topLevel = []; + data.forEach((datum) => { + if (isPaginatedResponse(datum)) { + const obj = this.process(datum, requestHref); + result.topLevel = [...result.topLevel, ...flattenSingleKeyObject(obj)]; + } else { + result.topLevel = [...result.topLevel, ...this.deserializeAndCache(datum, requestHref)]; + } + }); + } else { + Object.keys(data) + .filter((property) => data.hasOwnProperty(property)) + .filter((property) => hasValue(data[property])) + .forEach((property) => { + if (isPaginatedResponse(data[property])) { + const obj = this.process(data[property], requestHref); + result[property] = flattenSingleKeyObject(obj); + } else { + result[property] = this.deserializeAndCache(data[property], requestHref); + } + }); + } + return result; + } + } + } + + protected deserializeAndCache(obj, requestHref: string): NormalizedObject[] { + if (Array.isArray(obj)) { + let result = []; + obj.forEach((o) => result = [...result, ...this.deserializeAndCache(o, requestHref)]) + return result; + } + + const type: ResourceType = obj.type; + if (hasValue(type)) { + const normObjConstructor = NormalizedObjectFactory.getConstructor(type); + + if (hasValue(normObjConstructor)) { + const serializer = new DSpaceRESTv2Serializer(normObjConstructor); + + let processed; + if (isNotEmpty(obj._embedded)) { + processed = this.process(obj._embedded, requestHref); + } + const normalizedObj = serializer.deserialize(obj); + + if (isNotEmpty(processed)) { + const linksOnly = {}; + Object.keys(processed).forEach((key) => { + linksOnly[key] = processed[key].map((no: NormalizedObject) => no.self); + }); + Object.assign(normalizedObj, linksOnly); + } + + this.addToObjectCache(normalizedObj, requestHref); + return [normalizedObj]; + + } else { + // TODO: move check to Validator? + // throw new Error(`The server returned an object with an unknown a known type: ${type}`); + return []; + } + + } else { + // TODO: move check to Validator + // throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`); + return []; + } + } + + protected addToObjectCache(co: CacheableObject, requestHref: string): void { + if (hasNoValue(co) || hasNoValue(co.self)) { + throw new Error('The server returned an invalid object'); + } + this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref); + } + + protected processPageInfo(pageObj: any): PageInfo { + if (isNotEmpty(pageObj)) { + return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj); + } else { + return undefined; + } + } + +} +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index f362e7538a..d155910b4e 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -4,7 +4,6 @@ import { Store } from '@ngrx/store'; import { DataService } from './data.service'; import { Item } from '../shared/item.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { NormalizedItem } from '../cache/models/normalized-item.model'; @@ -14,11 +13,10 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Injectable() export class ItemDataService extends DataService { - protected resourceEndpoint = '/core/items'; + protected linkName = 'items'; protected browseEndpoint = '/discover/browses/dateissued/items'; constructor( - protected objectCache: ObjectCacheService, protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts new file mode 100644 index 0000000000..a137b99079 --- /dev/null +++ b/src/app/core/data/parsing.service.ts @@ -0,0 +1,7 @@ +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestRequest } from './request.models'; +import { RestResponse } from '../cache/response-cache.models'; + +export interface ResponseParsingService { + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse; +} diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index b5c53b19e4..b9f58a5567 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -14,7 +14,7 @@ export enum RemoteDataState { */ export class RemoteData { constructor( - public self: string, + public self: Observable, private requestPending: Observable, private responsePending: Observable, private isSuccessFul: Observable, diff --git a/src/app/core/data/request.actions.ts b/src/app/core/data/request.actions.ts index b17046005d..31f0dc5996 100644 --- a/src/app/core/data/request.actions.ts +++ b/src/app/core/data/request.actions.ts @@ -1,7 +1,6 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { Request } from './request.models'; +import { RestRequest } from './request.models'; /** * The list of RequestAction type definitions @@ -15,10 +14,10 @@ export const RequestActionTypes = { /* tslint:disable:max-classes-per-file */ export class RequestConfigureAction implements Action { type = RequestActionTypes.CONFIGURE; - payload: Request; + payload: RestRequest; constructor( - request: Request + request: RestRequest ) { this.payload = request; } diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index c5d7eb57f0..84f19679b1 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,48 +1,18 @@ -import { Injectable, Inject } from '@angular/core'; +import { Inject, Injectable, Injector } from '@angular/core'; import { Actions, Effect } from '@ngrx/effects'; - // tslint:disable-next-line:import-blacklist import { Observable } from 'rxjs'; -import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { Response, SuccessResponse, ErrorResponse } from '../cache/response-cache.models'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { RequestEntry } from './request.reducer'; -import { RequestActionTypes, RequestExecuteAction, RequestCompleteAction } from './request.actions'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { RequestService } from './request.service'; -import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; -import { ResourceType } from '../shared/resource-type'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RequestActionTypes, RequestCompleteAction, RequestExecuteAction } from './request.actions'; import { RequestError } from './request.models'; -import { PageInfo } from '../shared/page-info.model'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; - -import { GlobalConfig, GLOBAL_CONFIG } from '../../../config'; - -function isObjectLevel(halObj: any) { - return isNotEmpty(halObj._links) && hasValue(halObj._links.self); -} - -function isPaginatedResponse(halObj: any) { - return isNotEmpty(halObj.page) && hasValue(halObj._embedded); -} - -function flattenSingleKeyObject(obj: any): any { - const keys = Object.keys(obj); - if (keys.length !== 1) { - throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`); - } - return obj[keys[0]]; -} - -/* tslint:disable:max-classes-per-file */ -class ProcessRequestDTO { - [key: string]: NormalizedObject[] -} +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; @Injectable() export class RequestEffects { @@ -55,121 +25,23 @@ export class RequestEffects { }) .flatMap((entry: RequestEntry) => { return this.restApi.get(entry.request.href) - .map((data: DSpaceRESTV2Response) => { - const processRequestDTO = this.process(data.payload, entry.request.href); - const selfLinks = flattenSingleKeyObject(processRequestDTO).map((no) => no.self); - return new SuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload.page)) - }).do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) - .map((response: Response) => new RequestCompleteAction(entry.request.href)) + .map((data: DSpaceRESTV2Response) => + this.injector.get(entry.request.getResponseParser()).parse(entry.request, data)) + .do((response: RestResponse) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: RestResponse) => new RequestCompleteAction(entry.request.href)) .catch((error: RequestError) => Observable.of(new ErrorResponse(error)) - .do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) - .map((response: Response) => new RequestCompleteAction(entry.request.href))); + .do((response: RestResponse) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: RestResponse) => new RequestCompleteAction(entry.request.href))); }); constructor( @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, private actions$: Actions, private restApi: DSpaceRESTv2Service, - private objectCache: ObjectCacheService, + private injector: Injector, private responseCache: ResponseCacheService, protected requestService: RequestService ) { } - protected process(data: any, requestHref: string): ProcessRequestDTO { - - if (isNotEmpty(data)) { - if (isPaginatedResponse(data)) { - return this.process(data._embedded, requestHref); - } else if (isObjectLevel(data)) { - return { topLevel: this.deserializeAndCache(data, requestHref) }; - } else { - const result = new ProcessRequestDTO(); - if (Array.isArray(data)) { - result.topLevel = []; - data.forEach((datum) => { - if (isPaginatedResponse(datum)) { - const obj = this.process(datum, requestHref); - result.topLevel = [...result.topLevel, ...flattenSingleKeyObject(obj)]; - } else { - result.topLevel = [...result.topLevel, ...this.deserializeAndCache(datum, requestHref)]; - } - }); - } else { - Object.keys(data) - .filter((property) => data.hasOwnProperty(property)) - .filter((property) => hasValue(data[property])) - .forEach((property) => { - if (isPaginatedResponse(data[property])) { - const obj = this.process(data[property], requestHref); - result[property] = flattenSingleKeyObject(obj); - } else { - result[property] = this.deserializeAndCache(data[property], requestHref); - } - }); - } - return result; - } - } - } - - protected deserializeAndCache(obj, requestHref: string): NormalizedObject[] { - if (Array.isArray(obj)) { - let result = []; - obj.forEach((o) => result = [...result, ...this.deserializeAndCache(o, requestHref)]) - return result; - } - - const type: ResourceType = obj.type; - if (hasValue(type)) { - const normObjConstructor = NormalizedObjectFactory.getConstructor(type); - - if (hasValue(normObjConstructor)) { - const serializer = new DSpaceRESTv2Serializer(normObjConstructor); - - let processed; - if (isNotEmpty(obj._embedded)) { - processed = this.process(obj._embedded, requestHref); - } - const normalizedObj = serializer.deserialize(obj); - - if (isNotEmpty(processed)) { - const linksOnly = {}; - Object.keys(processed).forEach((key) => { - linksOnly[key] = processed[key].map((no: NormalizedObject) => no.self); - }); - Object.assign(normalizedObj, linksOnly); - } - - this.addToObjectCache(normalizedObj, requestHref); - return [normalizedObj]; - - } else { - // TODO: move check to Validator? - // throw new Error(`The server returned an object with an unknown a known type: ${type}`); - return []; - } - - } else { - // TODO: move check to Validator - // throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`); - return []; - } - } - - protected addToObjectCache(co: CacheableObject, requestHref: string): void { - if (hasNoValue(co) || hasNoValue(co.self)) { - throw new Error('The server returned an invalid object'); - } - this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref); - } - - protected processPageInfo(pageObj: any): PageInfo { - if (isNotEmpty(pageObj)) { - return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj); - } else { - return undefined; - } - } - } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index ec29fa1c5e..8c415e71ef 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -1,15 +1,23 @@ import { SortOptions } from '../cache/models/sort-options.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { GenericConstructor } from '../shared/generic-constructor'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RootResponseParsingService } from './root-response-parsing.service'; /* tslint:disable:max-classes-per-file */ -export class Request { +export class RestRequest { constructor( public href: string, ) { } + + getResponseParser(): GenericConstructor { + return DSOResponseParsingService; + } } -export class FindByIDRequest extends Request { +export class FindByIDRequest extends RestRequest { constructor( href: string, public resourceID: string @@ -25,7 +33,7 @@ export class FindAllOptions { sort?: SortOptions; } -export class FindAllRequest extends Request { +export class FindAllRequest extends RestRequest { constructor( href: string, public options?: FindAllOptions, @@ -34,6 +42,17 @@ export class FindAllRequest extends Request { } } +export class RootEndpointRequest extends RestRequest { + constructor(EnvConfig: GlobalConfig) { + const href = new RESTURLCombiner(EnvConfig, '/').toString(); + super(href); + } + + getResponseParser(): GenericConstructor { + return RootResponseParsingService; + } +} + export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index af7140bbf4..628725f745 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -1,12 +1,11 @@ -import { CacheableObject } from '../cache/object-cache.reducer'; import { RequestActionTypes, RequestAction, RequestConfigureAction, RequestExecuteAction, RequestCompleteAction } from './request.actions'; -import { Request } from './request.models'; +import { RestRequest } from './request.models'; export class RequestEntry { - request: Request; + request: RestRequest; requestPending: boolean; responsePending: boolean; completed: boolean; diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 7b401ca03a..e6b4f816f1 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -3,18 +3,18 @@ import { Injectable } from '@angular/core'; import { MemoizedSelector, Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; - -import { RequestEntry } from './request.reducer'; -import { Request } from './request.models'; import { hasValue } from '../../shared/empty.util'; -import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { CacheableObject } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOSuccessResponse } from '../cache/response-cache.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { SuccessResponse } from '../cache/response-cache.models'; +import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { keySelector } from '../shared/selectors'; +import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; +import { RestRequest } from './request.models'; + +import { RequestEntry } from './request.reducer'; function entryFromHrefSelector(href: string): MemoizedSelector { return keySelector('data/request', href); @@ -45,18 +45,25 @@ export class RequestService { return this.store.select(entryFromHrefSelector(href)); } - configure(request: Request): void { + configure(request: RestRequest): void { let isCached = this.objectCache.hasBySelfLink(request.href); if (!isCached && this.responseCache.has(request.href)) { - // if it isn't cached it may be a list endpoint, if so verify - // every object included in the response is still cached - this.responseCache.get(request.href) + const [dsoSuccessResponse, otherSuccessResponse] = this.responseCache.get(request.href) .take(1) .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as SuccessResponse).resourceSelfLinks) - .map((resourceSelfLinks: string[]) => resourceSelfLinks.every((selfLink) => this.objectCache.hasBySelfLink(selfLink))) - .subscribe((c) => isCached = c); + .map((entry: ResponseCacheEntry) => entry.response) + .share() + .partition((response: DSOSuccessResponse) => hasValue(response.resourceSelfLinks)); + + Observable.merge( + otherSuccessResponse.map(() => true), + dsoSuccessResponse // a DSOSuccessResponse should only be considered cached if all its resources are cached + .map((response: DSOSuccessResponse) => response.resourceSelfLinks) + .map((resourceSelfLinks: string[]) => resourceSelfLinks + .every((selfLink) => this.objectCache.hasBySelfLink(selfLink)) + ) + ).subscribe((c) => isCached = c); } const isPending = this.isPending(request.href); diff --git a/src/app/core/data/root-response-parsing.service.ts b/src/app/core/data/root-response-parsing.service.ts new file mode 100644 index 0000000000..a4841f69fb --- /dev/null +++ b/src/app/core/data/root-response-parsing.service.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@angular/core'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ErrorResponse, RestResponse, RootSuccessResponse } from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; + +@Injectable() +export class RootResponseParsingService implements ResponseParsingService { + constructor( + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, + ) { + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { + const links = data.payload._links; + for (const link of Object.keys(links)) { + let href = links[link].href; + // TODO temporary workaround as these endpoint paths are relative, but should be absolute + href = new RESTURLCombiner(this.EnvConfig, href.substring(this.EnvConfig.rest.nameSpace.length)).toString(); + links[link] = href; + } + return new RootSuccessResponse(links, data.statusCode); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from root endpoint'), + { statusText: data.statusCode } + ) + ); + } + } +} diff --git a/src/app/search/search.service.ts b/src/app/search/search.service.ts index 5562056ad2..9f43b0b4a6 100644 --- a/src/app/search/search.service.ts +++ b/src/app/search/search.service.ts @@ -78,7 +78,7 @@ export class SearchService { }); return new RemoteData( - self, + Observable.of(self), requestPending, responsePending, isSuccessFul,