diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 0351a9a54c..b51a9c834d 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -23,6 +23,7 @@ import { RequestService } from '../../core/data/request.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { configureRequest } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -78,7 +79,7 @@ export class SearchService implements OnDestroy { } }); }), - tap((request: RestRequest) => this.requestService.configure(request)), + configureRequest(this.requestService) ); const requestEntryObs = requestObs.pipe( flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) @@ -153,7 +154,7 @@ export class SearchService implements OnDestroy { } }); }), - tap((request: RestRequest) => this.requestService.configure(request)), + configureRequest(this.requestService) ); const requestEntryObs = requestObs.pipe( @@ -188,7 +189,7 @@ export class SearchService implements OnDestroy { } }); }), - tap((request: RestRequest) => this.requestService.configure(request)), + configureRequest(this.requestService) ); const requestEntryObs = requestObs.pipe( diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 2e9163fbac..5118ea7ecc 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -1,24 +1,28 @@ +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/Rx'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; -import { BrowseService } from './browse.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { RequestService } from '../data/request.service'; -import { hot, cold, getTestScheduler } from 'jasmine-marbles'; -import { BrowseDefinition } from '../shared/browse-definition.model'; -import { BrowseEndpointRequest } from '../data/request.models'; -import { TestScheduler } from 'rxjs/Rx'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { BrowseEndpointRequest, BrowseEntriesRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BrowseService } from './browse.service'; describe('BrowseService', () => { let scheduler: TestScheduler; let service: BrowseService; let responseCache: ResponseCacheService; let requestService: RequestService; + let rdbService: RemoteDataBuildService; const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const browseDefinitions = [ Object.assign(new BrowseDefinition(), { + id: 'date', metadataBrowse: false, sortOptions: [ { @@ -45,6 +49,7 @@ describe('BrowseService', () => { } }), Object.assign(new BrowseDefinition(), { + id: 'author', metadataBrowse: true, sortOptions: [ { @@ -80,7 +85,7 @@ describe('BrowseService', () => { b: { response: { isSuccessful, - browseDefinitions, + payload: browseDefinitions, } } })); @@ -91,7 +96,8 @@ describe('BrowseService', () => { return new BrowseService( responseCache, requestService, - halService + halService, + rdbService ); } @@ -99,15 +105,99 @@ describe('BrowseService', () => { scheduler = getTestScheduler(); }); + describe('getBrowseDefinitions', () => { + + beforeEach(() => { + responseCache = initMockResponseCacheService(true); + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(halService, 'getEndpoint').and + .returnValue(hot('--a-', { a: browsesEndpointURL })); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + it('should configure a new BrowseEndpointRequest', () => { + const expected = new BrowseEndpointRequest(requestService.generateRequestId(), browsesEndpointURL); + + scheduler.schedule(() => service.getBrowseDefinitions().subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getBrowseDefinitions(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + it('should return a RemoteData object containing the correct BrowseDefinition[]', () => { + const expected = cold('--a-', { a: { + payload: browseDefinitions + }}); + + expect(service.getBrowseDefinitions()).toBeObservable(expected); + }); + + }); + + describe('getBrowseEntriesFor', () => { + beforeEach(() => { + responseCache = initMockResponseCacheService(true); + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + service = initTestService(); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + }); + + describe('when called with a valid browse definition id', () => { + it('should configure a new BrowseEntriesRequest', () => { + const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); + + scheduler.schedule(() => service.getBrowseEntriesFor(browseDefinitions[1].id).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getBrowseEntriesFor(browseDefinitions[1].id); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + }); + + describe('when called with an invalid browse definition id', () => { + it('should throw an Error', () => { + + const definitionID = 'invalidID'; + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + + expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); + }); + }); + }); + describe('getBrowseURLFor', () => { - describe('if getEndpoint fires', () => { + describe('if getBrowseDefinitions fires', () => { beforeEach(() => { responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(halService, 'getEndpoint').and - .returnValue(hot('--a-', { a: browsesEndpointURL })); + spyOn(service, 'getBrowseDefinitions').and + .returnValue(hot('--a-', { a: { + payload: browseDefinitions + }})); }); it('should return the URL for the given metadatumKey and linkPath', () => { @@ -152,26 +242,15 @@ describe('BrowseService', () => { expect(result).toBeObservable(expected); }); - it('should configure a new BrowseEndpointRequest', () => { - const metadatumKey = 'dc.date.issued'; - const linkPath = 'items'; - const expected = new BrowseEndpointRequest(requestService.generateRequestId(), browsesEndpointURL); - - scheduler.schedule(() => service.getBrowseURLFor(metadatumKey, linkPath).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - - }); - }); - describe('if getEndpoint doesn\'t fire', () => { + describe('if getBrowseDefinitions doesn\'t fire', () => { it('should return undefined', () => { responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); service = initTestService(); - spyOn(halService, 'getEndpoint').and + spyOn(service, 'getBrowseDefinitions').and .returnValue(hot('----')); const metadatumKey = 'dc.date.issued'; @@ -182,22 +261,5 @@ describe('BrowseService', () => { expect(result).toBeObservable(expected); }); }); - - describe('if the browses endpoint can\'t be retrieved', () => { - it('should throw an error', () => { - responseCache = initMockResponseCacheService(false); - requestService = getMockRequestService(); - service = initTestService(); - spyOn(halService, 'getEndpoint').and - .returnValue(hot('--a-', { a: browsesEndpointURL })); - - const metadatumKey = 'dc.date.issued'; - const linkPath = 'items'; - - const result = service.getBrowseURLFor(metadatumKey, linkPath); - const expected = cold('c-#-', { c: undefined }, new Error(`Couldn't retrieve the browses endpoint`)); - expect(result).toBeObservable(expected); - }); - }); }); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 2e99dcc0d3..836014a110 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,15 +1,34 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; -import { GLOBAL_CONFIG } from '../../../config'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { + ensureArrayHasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotEmptyOperator +} from '../../shared/empty.util'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SortOptions } from '../cache/models/sort-options.model'; +import { GenericSuccessResponse } from '../cache/response-cache.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { BrowseEndpointRequest, RestRequest } from '../data/request.models'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { BrowseEndpointRequest, BrowseEntriesRequest, RestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + configureRequest, + filterSuccessfulResponses, + getRemoteDataPayload, + getRequestFromSelflink, + getResponseFromSelflink +} from '../shared/operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; @Injectable() export class BrowseService { @@ -31,42 +50,106 @@ export class BrowseService { constructor( protected responseCache: ResponseCacheService, protected requestService: RequestService, - protected halService: HALEndpointService) { + protected halService: HALEndpointService, + private rdb: RemoteDataBuildService, + ) { + } + + getBrowseDefinitions(): Observable> { + const request$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService) + ); + + const href$ = request$.pipe(map((request: RestRequest) => request.href)); + const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); + const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); + const payload$ = responseCache$.pipe( + filterSuccessfulResponses(), + map((entry: ResponseCacheEntry) => entry.response), + map((response: GenericSuccessResponse) => response.payload), + ensureArrayHasValue(), + distinctUntilChanged() + ); + + return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + } + + getBrowseEntriesFor(definitionID: string, options: { + pagination?: PaginationComponentOptions; + sort?: SortOptions; + } = {}): Observable>> { + const request$ = this.getBrowseDefinitions().pipe( + getRemoteDataPayload(), + map((browseDefinitions: BrowseDefinition[]) => browseDefinitions + .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + ), + map((def: BrowseDefinition) => { + if (isNotEmpty(def)) { + return def._links; + } else { + throw new Error(`No metadata browse definition could be found for id '${definitionID}'`); + } + }), + hasValueOperator(), + map((_links: any) => _links.entries), + hasValueOperator(), + map((href: string) => { + // TODO nearly identical to PaginatedSearchOptions => refactor + const args = []; + if (isNotEmpty(options.sort)) { + args.push(`sort=${options.sort.field},${options.sort.direction}`); + } + if (isNotEmpty(options.pagination)) { + args.push(`page=${options.pagination.currentPage - 1}`); + args.push(`size=${options.pagination.pageSize}`); + } + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService) + ); + + const href$ = request$.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); + const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); + + const payload$ = responseCache$.pipe( + filterSuccessfulResponses(), + map((entry: ResponseCacheEntry) => entry.response), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + distinctUntilChanged() + ); + + return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); } getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); - return this.halService.getEndpoint(this.linkPath) - .filter((href: string) => isNotEmpty(href)) - .distinctUntilChanged() - .map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL)) - .do((request: RestRequest) => this.requestService.configure(request)) - .flatMap((request: RestRequest) => { - const [successResponse, errorResponse] = this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) - .partition((response: RestResponse) => response.isSuccessful); - - return Observable.merge( - errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`Couldn't retrieve the browses endpoint`))), - successResponse - .filter((response: BrowseSuccessResponse) => isNotEmpty(response.browseDefinitions)) - .map((response: BrowseSuccessResponse) => response.browseDefinitions) - .map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => { - const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); - return isNotEmpty(matchingKeys); - }) - ).map((def: BrowseDefinition) => { - if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { - throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`); - } else { - return def._links[linkPath]; - } - }) - ); - }).startWith(undefined) - .distinctUntilChanged(); + return this.getBrowseDefinitions().pipe( + getRemoteDataPayload(), + map((browseDefinitions: BrowseDefinition[]) => browseDefinitions + .find((def: BrowseDefinition) => { + const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + return isNotEmpty(matchingKeys); + }) + ), + map((def: BrowseDefinition) => { + if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { + throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`); + } else { + return def._links[linkPath]; + } + }), + startWith(undefined), + distinctUntilChanged() + ); } } 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 ece80cf4ca..d576b9ea32 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,28 +1,25 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; -import { map, tap } from 'rxjs/operators'; -import { NormalizedSearchResult } from '../../../+search-page/normalized-search-result.model'; -import { SearchResult } from '../../../+search-page/search-result.model'; -import { SearchQueryResponse } from '../../../+search-page/search-service/search-query-response.model'; -import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { distinctUntilChanged, flatMap, map, startWith } from 'rxjs/operators'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; import { RemoteDataError } from '../../data/remote-data-error'; -import { GetRequest, RestRequest } from '../../data/request.models'; +import { GetRequest } from '../../data/request.models'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { GenericConstructor } from '../../shared/generic-constructor'; -import { NormalizedDSpaceObject } from '../models/normalized-dspace-object.model'; -import { NormalizedObjectFactory } from '../models/normalized-object-factory'; - -import { CacheableObject } from '../object-cache.reducer'; +import { NormalizedObject } from '../models/normalized-object.model'; import { ObjectCacheService } from '../object-cache.service'; -import { DSOSuccessResponse, ErrorResponse, SearchSuccessResponse } from '../response-cache.models'; +import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models'; import { ResponseCacheEntry } from '../response-cache.reducer'; import { ResponseCacheService } from '../response-cache.service'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; -import { NormalizedObject } from '../models/normalized-object.model'; +import { + getRequestFromSelflink, + getResourceLinksFromResponse, + getResponseFromSelflink, + filterSuccessfulResponses +} from '../../shared/operators'; @Injectable() export class RemoteDataBuildService { @@ -31,43 +28,42 @@ export class RemoteDataBuildService { protected requestService: RequestService) { } - buildSingle(hrefObs: string | Observable): Observable> { - if (typeof hrefObs === 'string') { - hrefObs = Observable.of(hrefObs); + buildSingle(href$: string | Observable): Observable> { + if (typeof href$ === 'string') { + href$ = Observable.of(href$); } - const requestHrefObs = hrefObs.flatMap((href: string) => - this.objectCache.getRequestHrefBySelfLink(href)); + const requestHref$ = href$.pipe(flatMap((href: string) => + this.objectCache.getRequestHrefBySelfLink(href))); - const requestEntryObs = Observable.race( - hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) - .filter((entry) => hasValue(entry)), - requestHrefObs.flatMap((requestHref) => - this.requestService.getByHref(requestHref)).filter((entry) => hasValue(entry)) + const requestEntry$ = Observable.race( + href$.pipe(getRequestFromSelflink(this.requestService)), + requestHref$.pipe(getRequestFromSelflink(this.requestService)) ); - const responseCacheObs = Observable.race( - hrefObs.flatMap((href: string) => this.responseCache.get(href)) - .filter((entry) => hasValue(entry)), - requestHrefObs.flatMap((requestHref) => this.responseCache.get(requestHref)).filter((entry) => hasValue(entry)) + const responseCache$ = Observable.race( + href$.pipe(getResponseFromSelflink(this.responseCache)), + requestHref$.pipe(getResponseFromSelflink(this.responseCache)) ); // always use self link if that is cached, only if it isn't, get it via the response. - const payloadObs = + const payload$ = Observable.combineLatest( - hrefObs.flatMap((href: string) => this.objectCache.getBySelfLink(href)) - .startWith(undefined), - responseCacheObs - .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) - .flatMap((resourceSelfLinks: string[]) => { + href$.pipe( + flatMap((href: string) => this.objectCache.getBySelfLink(href)), + startWith(undefined) + ), + responseCache$.pipe( + getResourceLinksFromResponse(), + flatMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { return this.objectCache.getBySelfLink(resourceSelfLinks[0]); } else { return Observable.of(undefined); } - }) - .distinctUntilChanged() - .startWith(undefined), + }), + distinctUntilChanged(), + startWith(undefined) + ), (fromSelfLink, fromResponse) => { if (hasValue(fromSelfLink)) { return fromSelfLink; @@ -75,17 +71,19 @@ export class RemoteDataBuildService { return fromResponse; } } - ).filter((normalized) => hasValue(normalized)) - .map((normalized: TNormalized) => { + ).pipe( + hasValueOperator(), + map((normalized: TNormalized) => { return this.build(normalized); - }) - .startWith(undefined) - .distinctUntilChanged(); - return this.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + }), + startWith(undefined), + distinctUntilChanged() + ); + return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); } - toRemoteDataObservable(requestEntryObs: Observable, responseCacheObs: Observable, payloadObs: Observable) { - return Observable.combineLatest(requestEntryObs, responseCacheObs.startWith(undefined), payloadObs, + toRemoteDataObservable(requestEntry$: Observable, responseCache$: Observable, payload$: Observable) { + return Observable.combineLatest(requestEntry$, responseCache$.startWith(undefined), payload$, (reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => { const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; @@ -109,33 +107,31 @@ export class RemoteDataBuildService { }); } - buildList(hrefObs: string | Observable): Observable>> { - if (typeof hrefObs === 'string') { - hrefObs = Observable.of(hrefObs); + buildList(href$: string | Observable): Observable>> { + if (typeof href$ === 'string') { + href$ = Observable.of(href$); } - const requestEntryObs = hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) - .filter((entry) => hasValue(entry)); - const responseCacheObs = hrefObs.flatMap((href: string) => this.responseCache.get(href)) - .filter((entry) => hasValue(entry)); + const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); + const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - const tDomainListObs = responseCacheObs - .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) - .flatMap((resourceUUIDs: string[]) => { + const tDomainList$ = responseCache$.pipe( + getResourceLinksFromResponse(), + flatMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs) .map((normList: TNormalized[]) => { return normList.map((normalized: TNormalized) => { return this.build(normalized); }); }); - }) - .startWith([]) - .distinctUntilChanged(); + }), + startWith([]), + distinctUntilChanged() + ); - const pageInfoObs = responseCacheObs - .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => { + const pageInfo$ = responseCache$.pipe( + filterSuccessfulResponses(), + map((entry: ResponseCacheEntry) => { if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { @@ -144,9 +140,10 @@ export class RemoteDataBuildService { return resPageInfo; } } - }); + }) + ); - const payloadObs = Observable.combineLatest(tDomainListObs, pageInfoObs, (tDomainList, pageInfo) => { + const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => { if (hasValue(pageInfo)) { return new PaginatedList(pageInfo, tDomainList); } else { @@ -154,7 +151,7 @@ export class RemoteDataBuildService { } }); - return this.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); } build(normalized: TNormalized): TDomain { diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index f061e78e6c..81fa9ac759 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -1,5 +1,6 @@ import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { RequestError } from '../data/request.models'; +import { BrowseEntry } from '../shared/browse-entry.model'; import { PageInfo } from '../shared/page-info.model'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { ConfigObject } from '../shared/config/config.model'; @@ -78,10 +79,11 @@ export class EndpointMapSuccessResponse extends RestResponse { } } -export class BrowseSuccessResponse extends RestResponse { +export class GenericSuccessResponse extends RestResponse { constructor( - public browseDefinitions: BrowseDefinition[], - public statusCode: string + public payload: T, + public statusCode: string, + public pageInfo?: PageInfo ) { super(true, statusCode); } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index ab0e951731..be99f376da 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -15,6 +15,7 @@ import { coreReducers } from './core.reducers'; import { isNotEmpty } from '../shared/empty.util'; import { ApiService } from '../shared/api.service'; +import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; import { DebugResponseParsingService } from './data/debug-response-parsing.service'; @@ -83,6 +84,7 @@ const PROVIDERS = [ SearchResponseParsingService, ServerResponseService, BrowseResponseParsingService, + BrowseEntriesResponseParsingService, BrowseService, ConfigResponseParsingService, RouteService, diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts new file mode 100644 index 0000000000..171def60df --- /dev/null +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@angular/core'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + ErrorResponse, + GenericSuccessResponse, + RestResponse +} from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { BrowseEntry } from '../shared/browse-entry.model'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; + +@Injectable() +export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = { + getConstructor: () => BrowseEntry + }; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) + && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(BrowseEntry); + const browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + return new GenericSuccessResponse(browseEntries, data.statusCode, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusText: data.statusCode } + ) + ); + } + } + +} diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 3e3d2190f7..5ba8a50d7b 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseEndpointRequest } from './request.models'; -import { BrowseSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -138,9 +138,9 @@ describe('BrowseResponseParsingService', () => { }) ]; - it('should return a BrowseSuccessResponse if data contains a valid browse endpoint response', () => { + it('should return a GenericSuccessResponse if data contains a valid browse endpoint response', () => { const response = service.parse(validRequest, validResponse); - expect(response.constructor).toBe(BrowseSuccessResponse); + expect(response.constructor).toBe(GenericSuccessResponse); }); it('should return an ErrorResponse if data contains an invalid browse endpoint response', () => { @@ -155,9 +155,9 @@ describe('BrowseResponseParsingService', () => { expect(response.constructor).toBe(ErrorResponse); }); - it('should return a BrowseSuccessResponse with the BrowseDefinitions in data', () => { + it('should return a GenericSuccessResponse with the BrowseDefinitions in data', () => { const response = service.parse(validRequest, validResponse); - expect((response as BrowseSuccessResponse).browseDefinitions).toEqual(definitions); + expect((response as GenericSuccessResponse).payload).toEqual(definitions); }); }); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index 8633e7269a..8feb1bc82b 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { isNotEmpty } from '../../shared/empty.util'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; @@ -15,7 +15,7 @@ export class BrowseResponseParsingService implements ResponseParsingService { && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new BrowseSuccessResponse(browseDefinitions, data.statusCode); + return new GenericSuccessResponse(browseDefinitions, data.statusCode); } else { return new ErrorResponse( Object.assign( diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 21df69b3a2..2591d76fda 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -2,6 +2,7 @@ import { SortOptions } from '../cache/models/sort-options.model'; import { GenericConstructor } from '../shared/generic-constructor'; import { GlobalConfig } from '../../../config/global-config.interface'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; +import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; @@ -164,6 +165,12 @@ export class BrowseEndpointRequest extends GetRequest { } } +export class BrowseEntriesRequest extends GetRequest { + getResponseParser(): GenericConstructor { + return BrowseEntriesResponseParsingService; + } +} + export class ConfigRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index bdb91167b0..05263858c6 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -2,6 +2,9 @@ import { autoserialize, autoserializeAs } from 'cerialize'; import { SortOption } from './sort-option.model'; export class BrowseDefinition { + @autoserialize + id: string; + @autoserialize metadataBrowse: boolean; diff --git a/src/app/core/shared/browse-entry.model.ts b/src/app/core/shared/browse-entry.model.ts new file mode 100644 index 0000000000..fede195a39 --- /dev/null +++ b/src/app/core/shared/browse-entry.model.ts @@ -0,0 +1,20 @@ +import { autoserialize, autoserializeAs } from 'cerialize'; + +export class BrowseEntry { + + @autoserialize + type: string; + + @autoserialize + authority: string; + + @autoserialize + value: string; + + @autoserializeAs('valueLang') + language: string; + + @autoserialize + count: number; + +} diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts new file mode 100644 index 0000000000..b2a725bfa6 --- /dev/null +++ b/src/app/core/shared/operators.ts @@ -0,0 +1,47 @@ +import { Observable } from 'rxjs/Observable'; +import { filter, flatMap, map, tap } from 'rxjs/operators'; +import { hasValueOperator } from '../../shared/empty.util'; +import { DSOSuccessResponse } from '../cache/response-cache.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RemoteData } from '../data/remote-data'; +import { RestRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; +import { RequestService } from '../data/request.service'; + +/** + * This file contains custom RxJS operators that can be used in multiple places + */ + +export const getRequestFromSelflink = (requestService: RequestService) => + (source: Observable): Observable => + source.pipe( + flatMap((href: string) => requestService.getByHref(href)), + hasValueOperator() + ); + +export const getResponseFromSelflink = (responseCache: ResponseCacheService) => + (source: Observable): Observable => + source.pipe( + flatMap((href: string) => responseCache.get(href)), + hasValueOperator() + ); + +export const filterSuccessfulResponses = () => + (source: Observable): Observable => + source.pipe(filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)); + +export const getResourceLinksFromResponse = () => + (source: Observable): Observable => + source.pipe( + filterSuccessfulResponses(), + map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks), + ); + +export const configureRequest = (requestService: RequestService) => + (source: Observable): Observable => + source.pipe(tap((request: RestRequest) => requestService.configure(request))); + +export const getRemoteDataPayload = () => + (source: Observable>): Observable => + source.pipe(map((remoteData: RemoteData) => remoteData.payload)); diff --git a/src/app/shared/empty.util.spec.ts b/src/app/shared/empty.util.spec.ts index 509f55f7f8..1112883c2a 100644 --- a/src/app/shared/empty.util.spec.ts +++ b/src/app/shared/empty.util.spec.ts @@ -1,6 +1,16 @@ +import { cold, hot } from 'jasmine-marbles'; import { - isEmpty, hasNoValue, hasValue, isNotEmpty, isNull, isNotNull, - isUndefined, isNotUndefined + ensureArrayHasValue, + hasNoValue, + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotEmptyOperator, + isNotNull, + isNotUndefined, + isNull, + isUndefined } from './empty.util'; describe('Empty Utils', () => { @@ -274,6 +284,25 @@ describe('Empty Utils', () => { }); + describe('hasValueOperator', () => { + it('should only include items from the source observable for which hasValue is true, and omit all others', () => { + const testData = { + a: null, + b: 'test', + c: true, + d: undefined, + e: 1, + f: {} + }; + + const source$ = hot('abcdef', testData); + const expected$ = cold('-bc-ef', testData); + const result$ = source$.pipe(hasValueOperator()); + + expect(result$).toBeObservable(expected$); + }); + }); + describe('isEmpty', () => { it('should return true for null', () => { expect(isEmpty(null)).toBe(true); @@ -393,4 +422,56 @@ describe('Empty Utils', () => { }); }); + + describe('isNotEmptyOperator', () => { + it('should only include items from the source observable for which isNotEmpty is true, and omit all others', () => { + const testData = { + a: null, + b: 'test', + c: true, + d: undefined, + e: 1, + f: {}, + g: '', + h: ' ' + }; + + const source$ = hot('abcdefgh', testData); + const expected$ = cold('-bc-e--h', testData); + const result$ = source$.pipe(isNotEmptyOperator()); + + expect(result$).toBeObservable(expected$); + }); + }); + + describe('ensureArrayHasValue', () => { + it('should let all arrays pass unchanged, and turn everything else in to empty arrays', () => { + const sourceData = { + a: { a: 'b' }, + b: ['a', 'b', 'c'], + c: null, + d: [1], + e: undefined, + f: [], + g: () => true, + h: {}, + i: '' + }; + + const expectedData = Object.assign({}, sourceData, { + a: [], + c: [], + e: [], + g: [], + h: [], + i: [] + }); + + const source$ = hot('abcdefghi', sourceData); + const expected$ = cold('abcdefghi', expectedData); + const result$ = source$.pipe(ensureArrayHasValue()); + + expect(result$).toBeObservable(expected$); + }); + }); }); diff --git a/src/app/shared/empty.util.ts b/src/app/shared/empty.util.ts index 1dc3f71871..c1498d11af 100644 --- a/src/app/shared/empty.util.ts +++ b/src/app/shared/empty.util.ts @@ -1,3 +1,6 @@ +import { Observable } from 'rxjs/Observable'; +import { filter, map } from 'rxjs/operators'; + /** * Returns true if the passed value is null. * isNull(); // false @@ -82,6 +85,14 @@ export function hasValue(obj?: any): boolean { return isNotUndefined(obj) && isNotNull(obj); } +/** + * Filter items emitted by the source Observable by only emitting those for + * which hasValue is true + */ +export const hasValueOperator = () => + (source: Observable): Observable => + source.pipe(filter((obj: T) => hasValue(obj))); + /** * Verifies that a value is `null` or an empty string, empty array, * or empty function. @@ -148,3 +159,21 @@ export function isEmpty(obj?: any): boolean { export function isNotEmpty(obj?: any): boolean { return !isEmpty(obj); } + +/** + * Filter items emitted by the source Observable by only emitting those for + * which isNotEmpty is true + */ +export const isNotEmptyOperator = () => + (source: Observable): Observable => + source.pipe(filter((obj: T) => isNotEmpty(obj))); + +/** + * Tests each value emitted by the source Observable, + * let's arrays pass through, turns other values in to + * empty arrays. Used to be able to chain array operators + * on something that may not have a value + */ +export const ensureArrayHasValue = () => + (source: Observable): Observable => + source.pipe(map((arr: T[]): T[] => Array.isArray(arr) ? arr : [])); diff --git a/src/app/shared/mocks/mock-remote-data-build.service.ts b/src/app/shared/mocks/mock-remote-data-build.service.ts new file mode 100644 index 0000000000..c10032eb94 --- /dev/null +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -0,0 +1,23 @@ +import { Observable } from 'rxjs/Observable'; +import { map, take } from 'rxjs/operators'; +import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; +import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; +import { RemoteData } from '../../core/data/remote-data'; +import { RequestEntry } from '../../core/data/request.reducer'; +import { hasValue } from '../empty.util'; + +export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observable>): RemoteDataBuildService { + return { + toRemoteDataObservable: (requestEntry$: Observable, responseCache$: Observable, payload$: Observable) => { + + if (hasValue(toRemoteDataObservable$)) { + return toRemoteDataObservable$; + } else { + return payload$.pipe(map((payload) => ({ + payload + } as RemoteData))) + } + } + } as RemoteDataBuildService; + +}