From 8f4b3b58fba9fa228e708543d8fe63e835f35cd7 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 24 Aug 2022 12:54:27 +0200 Subject: [PATCH] 93803: Make data services composable Data services should extend BaseDataService (or IdentifiableDataService) for low-level functionality and optionally wrap "data service feature" classes for - create - findAll - patch / update - put - delete --- .../core/data/base/base-data.service.spec.ts | 627 ++++++++++++++++++ src/app/core/data/base/base-data.service.ts | 373 +++++++++++ src/app/core/data/base/create-data.spec.ts | 112 ++++ src/app/core/data/base/create-data.ts | 107 +++ src/app/core/data/base/delete-data.spec.ts | 208 ++++++ src/app/core/data/base/delete-data.ts | 108 +++ src/app/core/data/base/find-all-data.spec.ts | 306 +++++++++ src/app/core/data/base/find-all-data.ts | 112 ++++ .../base/identifiable-data.service.spec.ts | 145 ++++ .../data/base/identifiable-data.service.ts | 83 +++ src/app/core/data/base/patch-data.spec.ts | 180 +++++ src/app/core/data/base/patch-data.ts | 143 ++++ src/app/core/data/base/put-data.spec.ts | 108 +++ src/app/core/data/base/put-data.ts | 69 ++ src/app/core/data/base/search-data.spec.ts | 146 ++++ src/app/core/data/base/search-data.ts | 145 ++++ 16 files changed, 2972 insertions(+) create mode 100644 src/app/core/data/base/base-data.service.spec.ts create mode 100644 src/app/core/data/base/base-data.service.ts create mode 100644 src/app/core/data/base/create-data.spec.ts create mode 100644 src/app/core/data/base/create-data.ts create mode 100644 src/app/core/data/base/delete-data.spec.ts create mode 100644 src/app/core/data/base/delete-data.ts create mode 100644 src/app/core/data/base/find-all-data.spec.ts create mode 100644 src/app/core/data/base/find-all-data.ts create mode 100644 src/app/core/data/base/identifiable-data.service.spec.ts create mode 100644 src/app/core/data/base/identifiable-data.service.ts create mode 100644 src/app/core/data/base/patch-data.spec.ts create mode 100644 src/app/core/data/base/patch-data.ts create mode 100644 src/app/core/data/base/put-data.spec.ts create mode 100644 src/app/core/data/base/put-data.ts create mode 100644 src/app/core/data/base/search-data.spec.ts create mode 100644 src/app/core/data/base/search-data.ts diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts new file mode 100644 index 0000000000..973b9d5095 --- /dev/null +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -0,0 +1,627 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { BaseDataService } from './base-data.service'; + +const endpoint = 'https://rest.api/core'; + +const BOOLEAN = { f: false, t: true }; + +class TestService extends BaseDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('BaseDataService', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe(`reRequestStaleRemoteData`, () => { + let callback: jasmine.Spy; + + beforeEach(() => { + callback = jasmine.createSpy(); + }); + + + describe(`when shouldReRequest is false`, () => { + it(`shouldn't do anything`, () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + const expected = 'a-b-c-d-e-f'; + const values = { + a: remoteDataMocks.RequestPending, + b: remoteDataMocks.ResponsePending, + c: remoteDataMocks.Success, + d: remoteDataMocks.SuccessStale, + e: remoteDataMocks.Error, + f: remoteDataMocks.ErrorStale, + }; + + expectObservable((service as any).reRequestStaleRemoteData(false, callback)(cold(expected, values))).toBe(expected, values); + // since the callback happens in a tap(), flush to ensure it has been executed + flush(); + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); + + describe(`when shouldReRequest is true`, () => { + it(`should call the callback for stale RemoteData objects, but still pass the source observable unmodified`, () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + const expected = 'a-b'; + const values = { + a: remoteDataMocks.SuccessStale, + b: remoteDataMocks.ErrorStale, + }; + + expectObservable((service as any).reRequestStaleRemoteData(true, callback)(cold(expected, values))).toBe(expected, values); + // since the callback happens in a tap(), flush to ensure it has been executed + flush(); + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + it(`should only call the callback for stale RemoteData objects if something is subscribed to it`, (done) => { + testScheduler.run(({ cold, expectObservable }) => { + const expected = 'a'; + const values = { + a: remoteDataMocks.SuccessStale, + }; + + const result$ = (service as any).reRequestStaleRemoteData(true, callback)(cold(expected, values)); + expectObservable(result$).toBe(expected, values); + expect(callback).not.toHaveBeenCalled(); + result$.subscribe(() => { + expect(callback).toHaveBeenCalled(); + done(); + }); + }); + }); + + it(`shouldn't do anything for RemoteData objects that aren't stale`, () => { + testScheduler.run(({ cold, expectObservable, flush }) => { + const expected = 'a-b-c-d'; + const values = { + a: remoteDataMocks.RequestPending, + b: remoteDataMocks.ResponsePending, + c: remoteDataMocks.Success, + d: remoteDataMocks.Error, + }; + + expectObservable((service as any).reRequestStaleRemoteData(true, callback)(cold(expected, values))).toBe(expected, values); + // since the callback happens in a tap(), flush to ensure it has been executed + flush(); + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); + + }); + + describe(`findByHref`, () => { + beforeEach(() => { + spyOn(service as any, 'createAndSendGetRequest').and.callFake((href$) => { href$.subscribe().unsubscribe(); }); + }); + + it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { + testScheduler.run(({ cold }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findByHref(selfLink, true, true, ...linksToFollow); + expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, {}, [], ...linksToFollow); + }); + }); + + it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findByHref(selfLink, true, true, ...linksToFollow); + expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); + expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); + + service.findByHref(selfLink, false, true, ...linksToFollow); + expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false); + expectObservable(rdbService.buildSingle.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' }); + }); + }); + + it(`should call rdbService.buildSingle with the result from buildHrefFromFindOptions and linksToFollow`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findByHref(selfLink, true, true, ...linksToFollow); + expect(rdbService.buildSingle).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); + expectObservable(rdbService.buildSingle.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); + }); + }); + + it(`should return a the output from reRequestStaleRemoteData`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); + const expected = 'a'; + const values = { + a: 'bingo!', + }; + + expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findByHref call as a callback`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + + service.findByHref(selfLink, true, true, ...linksToFollow); + expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); + spyOn(service, 'findByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + // prove that the spy we just added hasn't been called yet + expect(service.findByHref).not.toHaveBeenCalled(); + // call the callback passed to reRequestStaleRemoteData + (service as any).reRequestStaleRemoteData.calls.argsFor(0)[1](); + // verify that findByHref _has_ been called now, with the same params as the original call + expect(service.findByHref).toHaveBeenCalledWith(jasmine.anything(), true, true, ...linksToFollow); + // ... except for selflink, which will have been turned in to an observable. + expectObservable((service.findByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); + }); + }); + + describe(`when useCachedVersionIfAvailable is true`, () => { + beforeEach(() => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); + }); + + it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = 'a-b-c-d-e'; + const values = { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.SuccessStale, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + }); + + describe(`when useCachedVersionIfAvailable is false`, () => { + beforeEach(() => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); + }); + + + it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.SuccessStale, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + }); + + }); + + describe(`findAllByHref`, () => { + let findListOptions; + beforeEach(() => { + findListOptions = { currentPage: 5 }; + spyOn(service as any, 'createAndSendGetRequest').and.callFake((href$) => { href$.subscribe().unsubscribe(); }); + }); + + it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { + testScheduler.run(({ cold }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); + }); + }); + + it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); + expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); + + service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow); + expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false); + expectObservable(rdbService.buildList.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' }); + }); + }); + + it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + + service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); + expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); + }); + }); + + it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findAllByHref call as a callback`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + + service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); + spyOn(service, 'findAllByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + // prove that the spy we just added hasn't been called yet + expect(service.findAllByHref).not.toHaveBeenCalled(); + // call the callback passed to reRequestStaleRemoteData + (service as any).reRequestStaleRemoteData.calls.argsFor(0)[1](); + // verify that findAllByHref _has_ been called now, with the same params as the original call + expect(service.findAllByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); + // ... except for selflink, which will have been turned in to an observable. + expectObservable((service.findAllByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); + }); + }); + + it(`should return a the output from reRequestStaleRemoteData`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); + const expected = 'a'; + const values = { + a: 'bingo!', + }; + + expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + describe(`when useCachedVersionIfAvailable is true`, () => { + beforeEach(() => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); + }); + + it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = 'a-b-c-d-e'; + const values = { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.SuccessStale, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + }); + + describe(`when useCachedVersionIfAvailable is false`, () => { + beforeEach(() => { + spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); + spyOn(service as any, 'reRequestStaleRemoteData').and.callFake(() => (source) => source); + }); + + + it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { + testScheduler.run(({ cold, expectObservable }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { + a: remoteDataMocks.SuccessStale, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + })); + const expected = '--b-c-d-e'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + e: remoteDataMocks.SuccessStale, + }; + + expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + }); + }); + + }); + }); + + describe('invalidateByHref', () => { + let getByHrefSpy: jasmine.Spy; + + beforeEach(() => { + getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2', 'request3'] + })); + + }); + + it('should call setStaleByUUID for every request associated with this DSO', (done) => { + service.invalidateByHref('some-href').subscribe((ok) => { + expect(ok).toBeTrue(); + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + done(); + }); + }); + + it('should call setStaleByUUID even if not subscribing to returned Observable', fakeAsync(() => { + service.invalidateByHref('some-href'); + tick(); + + expect(getByHrefSpy).toHaveBeenCalledWith('some-href'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + })); + + it('should return an Observable that only emits true once all requests are stale', () => { + testScheduler.run(({ cold, expectObservable }) => { + requestService.setStaleByUUID.and.callFake((uuid) => { + switch (uuid) { // fake requests becoming stale at different times + case 'request1': + return cold('--(t|)', BOOLEAN); + case 'request2': + return cold('----(t|)', BOOLEAN); + case 'request3': + return cold('------(t|)', BOOLEAN); + } + }); + + const done$ = service.invalidateByHref('some-href'); + + // emit true as soon as the final request is stale + expectObservable(done$).toBe('------(t|)', BOOLEAN); + }); + }); + + it('should only fire for the current state of the object (instead of tracking it)', () => { + testScheduler.run(({ cold, flush }) => { + getByHrefSpy.and.returnValue(cold('a---b---c---', { + a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache + b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state + c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't + })); + + service.invalidateByHref('some-href'); + flush(); + + // requests from the first state are marked as stale + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + + // request from subsequent states are ignored + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request2'); + expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request3'); + }); + }); + }); +}); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts new file mode 100644 index 0000000000..9679bddf1b --- /dev/null +++ b/src/app/core/data/base/base-data.service.ts @@ -0,0 +1,373 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; +import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { URLCombiner } from '../../url-combiner/url-combiner'; +import { RemoteData } from '../remote-data'; +import { GetRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; + +/** + * Common functionality for data services. + * Specific functionality that not all services would need + * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * + * All DataService (or DataService feature) classes must + * - extend this class (or {@link IdentifiableDataService}) + * - implement any DataService features it requires in order to forward calls to it + * + * ``` + * export class SomeDataService extends BaseDataService implements CreateData, SearchData { + * private createData: CreateData; + * private searchData: SearchDataData; + * + * create(...) { + * return this.createData.create(...); + * } + * + * searchBy(...) { + * return this.searchData.searchBy(...); + * } + * } + * ``` + */ +export class BaseDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + ) { + } + + /** + * Allows subclasses to reset the response cache time. + */ + + /** + * Get the endpoint for browsing + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @returns {Observable} + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.getEndpoint(); + } + + /** + * Get the base endpoint for all requests + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Turn an options object into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param options The [[FindListOptions]] object + * @param extraArgs Array with additional params to combine with query string + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig[]): string { + let args = [...extraArgs]; + + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { + /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ + args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`); + } + if (hasValue(options.elementsPerPage)) { + args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`); + } + if (hasValue(options.sort)) { + args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`); + } + if (hasValue(options.startsWith)) { + args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`); + } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + args = this.addEmbedParams(href, args, ...linksToFollow); + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + + /** + * Turn an array of RequestParam into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param params Array with additional params to combine with query string + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * + * @return {Observable} + * Return an observable that emits created HREF + */ + buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + + args = this.addEmbedParams(href, args, ...linksToFollow); + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + /** + * Adds the embed options to the link for the request + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + const embedString = 'embed=' + String(linkToFollow.name); + // Add the embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + args = this.addHrefArg(href, args, + 'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage); + } + // Adds the nested embeds and their size if given + if (isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, embedString); + } + } + }); + return args; + } + + /** + * Add a new argument to the list of arguments, only if it doesn't already exist in the given href, + * or the current list of arguments + * + * @param href The href the arguments are to be added to + * @param currentArgs The current list of arguments + * @param newArg The new argument to add + * @return The next list of arguments, with newArg included if it wasn't already. + * Note this function will not modify any of the input params. + */ + protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { + if (href.includes(newArg) || currentArgs.includes(newArg)) { + return [...currentArgs]; + } else { + return [...currentArgs, newArg]; + } + } + + /** + * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively + * @param embedString embedString so far (recursive) + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig[]): string[] { + let nestEmbed = embedString; + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + nestEmbed = nestEmbed + '/' + String(linkToFollow.name); + // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; + args = this.addHrefArg(href, args, nestedEmbedSize); + } + if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, nestEmbed); + } + } + }); + return args; + } + + /** + * An operator that will call the given function if the incoming RemoteData is stale and + * shouldReRequest is true + * + * @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale + * @param requestFn The function to call if the RemoteData is stale and shouldReRequest is + * true + */ + protected reRequestStaleRemoteData(shouldReRequest: boolean, requestFn: () => Observable>) { + return (source: Observable>): Observable> => { + if (shouldReRequest === true) { + return source.pipe( + tap((remoteData: RemoteData) => { + if (hasValue(remoteData) && remoteData.isStale) { + requestFn(); + } + }) + ); + } else { + return source; + } + }; + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findByHref(href$: string | Observable, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + // This skip ensures that if a stale object is present in the cache when you do a + // call it isn't immediately returned, but we wait until the remote data for the new request + // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a + // cached completed object + skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of list we want to retrieve. Can be a string or an Observable + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findAllByHref(href$: string | Observable, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + // This skip ensures that if a stale object is present in the cache when you do a + // call it isn't immediately returned, but we wait until the remote data for the new request + // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a + // cached completed object + skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findAllByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Create a GET request for the given href, and send it. + * + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + */ + protected createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable = true): void { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = this.requestService.generateRequestId(); + const request = new GetRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request, useCachedVersionIfAvailable); + }); + } + } + + /** + * Return the links to traverse from the root of the api to the + * endpoint this DataService represents + * + * e.g. if the api root links to 'foo', and the endpoint at 'foo' + * links to 'bar' the linkPath for the BarDataService would be + * 'foo/bar' + */ + getLinkPath(): string { + return this.linkPath; + } + + /** + * Invalidate a cached object by its href + * @param href the href to invalidate + */ + public invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + take(1), + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } +} diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts new file mode 100644 index 0000000000..ceefd3c51d --- /dev/null +++ b/src/app/core/data/base/create-data.spec.ts @@ -0,0 +1,112 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { CreateDataImpl } from './create-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends CreateDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, notificationsService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('CreateDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + notificationsService = {} as NotificationsService; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + // todo: add specs (there were no ceate specs in original DataService suite!) +}); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts new file mode 100644 index 0000000000..3ffcd9adf2 --- /dev/null +++ b/src/app/core/data/base/create-data.ts @@ -0,0 +1,107 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; +import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { getClassForType } from '../../cache/builders/build-decorators'; +import { CreateRequest } from '../request.models'; +import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; + +/** + * Interface for a data service that can create objects. + */ +export interface CreateData { + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable>; +} + +/** + * A DataService feature to create objects. + * + * Concrete data services can use this feature by implementing {@link CreateData} + * and delegating its method to an inner instance of this class. + */ +export class CreateDataImpl extends BaseDataService implements CreateData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + const endpoint$ = this.getEndpoint().pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpoint: string) => this.buildHrefWithParams(endpoint, params)), + ); + return this.createOnEndpoint(object, endpoint$); + } + + /** + * Send a POST request to create a new resource to a specific endpoint. + * Use this method if the endpoint needs to be adjusted. In most cases {@link create} should be sufficient. + * @param object the object to create + * @param endpoint$ the endpoint to send the POST request to + */ + createOnEndpoint(object: T, endpoint$: Observable): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); + + endpoint$.pipe( + take(1), + ).subscribe((endpoint: string) => { + const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject)); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + const result$ = this.rdbService.buildFromRequestUUID(requestId); + + // TODO a dataservice is not the best place to show a notification, + // this should move up to the components that use this method + result$.pipe( + takeWhile((rd: RemoteData) => rd.isLoading, true) + ).subscribe((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); + } + }); + + return result$; + } +} diff --git a/src/app/core/data/base/delete-data.spec.ts b/src/app/core/data/base/delete-data.spec.ts new file mode 100644 index 0000000000..4c4a2ded6d --- /dev/null +++ b/src/app/core/data/base/delete-data.spec.ts @@ -0,0 +1,208 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { DeleteDataImpl } from './delete-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { fakeAsync, tick } from '@angular/core/testing'; + +const endpoint = 'https://rest.api/core'; + +const BOOLEAN = { f: false, t: true }; + +class TestService extends DeleteDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, notificationsService, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('DeleteDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + notificationsService = {} as NotificationsService; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('delete', () => { + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let invalidateByHrefSpy: jasmine.Spy; + let buildFromRequestUUIDSpy: jasmine.Spy; + let getIDHrefObsSpy: jasmine.Spy; + let deleteByHrefSpy: jasmine.Spy; + + beforeEach(() => { + invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); + getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough(); + deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + it('should retrieve href by ID and call deleteByHref', () => { + getIDHrefObsSpy.and.returnValue(observableOf('some-href')); + buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); + + service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => { + expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id'); + expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']); + }); + }); + + describe('deleteByHref', () => { + it('should call invalidateByHref if the DELETE request succeeds', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_SUCCEEDED_RD); + expect(invalidateByHrefSpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href'); + tick(); + + expect(invalidateByHrefSpy).toHaveBeenCalled(); + })); + + it('should not call invalidateByHref if the DELETE request fails', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_FAILED_RD); + expect(invalidateByHrefSpy).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should wait for invalidateByHref before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away + ); + invalidateByHrefSpy.and.returnValue( + cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer + ); + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done + ); + }); + }); + + it('should wait for the DELETE request to resolve before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while + ); + invalidateByHrefSpy.and.returnValue( + cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner + ); // e.g.: maybe already stale before this call? + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request + ); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts new file mode 100644 index 0000000000..2e34f0957c --- /dev/null +++ b/src/app/core/data/base/delete-data.ts @@ -0,0 +1,108 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { AsyncSubject, combineLatest, Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { NoContent } from '../../shared/NoContent.model'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { DeleteRequest } from '../request.models'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; + +export interface DeleteData { + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable>; + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable>; +} + +export class DeleteDataImpl extends IdentifiableDataService implements DeleteData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`DeleteDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)), + ); + } + + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id, + ); + } + + const request = new DeleteRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return this.invalidateByHref(href); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return combineLatest([response$, invalidated$]).pipe( + filter(([_, invalidated]) => invalidated), + map(([response, _]) => response), + ); + } +} diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts new file mode 100644 index 0000000000..3caa8990f6 --- /dev/null +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -0,0 +1,306 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { FindAllData, FindAllDataImpl } from './find-all-data'; +import createSpyObj = jasmine.createSpyObj; +import { FindListOptions } from '../find-list-options.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; +import { RequestParam } from '../../cache/models/request-param.model'; + +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Observable, of as observableOf } from 'rxjs'; + +/** + * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it + */ +export function testFindAllDataImplementation(service: FindAllData, methods = ['findAll', 'getFindAllHref']) { + describe('FindAllData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeEach(() => { + (service as any).findAllData = createSpyObj('findAllData', { + findAll: 'TEST findAll', + getFindAllHref: 'TEST getFindAllHref', + }); + }); + + if ('findAll' in methods) { + it('should handle calls to findAll', () => { + const out: any = service.findAll(OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).findAllData.findAll).toHaveBeenCalledWith(OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST findAll'); + }); + } + + if ('getFindAllHref' in methods) { + it('should handle calls to getFindAllHref', () => { + const out: any = service.getFindAllHref(OPTIONS, 'linkPath', ...FOLLOWLINKS); + + expect((service as any).findAllData.getFindAllHref).toHaveBeenCalledWith(OPTIONS, 'linkPath', ...FOLLOWLINKS); + expect(out).toBe('TEST getFindAllHref'); + }); + } + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends FindAllDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('FindAllDataImpl', () => { + let service: TestService; + let options: FindListOptions; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getFindAllHref', () => { + + it('should return an observable with the endpoint', () => { + options = {}; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(endpoint); + }, + ); + }); + + it('should include page in href if currentPage provided in options', () => { + options = { currentPage: 2 }; + const expected = `${endpoint}?page=${options.currentPage - 1}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include size in href if elementsPerPage provided in options', () => { + options = { elementsPerPage: 5 }; + const expected = `${endpoint}?size=${options.elementsPerPage}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include sort href if SortOptions provided in options', () => { + const sortOptions = new SortOptions('field1', SortDirection.ASC); + options = { sort: sortOptions }; + const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include startsWith in href if startsWith provided in options', () => { + options = { startsWith: 'ab' }; + const expected = `${endpoint}?startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all provided options in href', () => { + const sortOptions = new SortOptions('field1', SortDirection.DESC); + options = { + currentPage: 6, + elementsPerPage: 10, + sort: sortOptions, + startsWith: 'ab', + + }; + const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all searchParams in href if any provided in options', () => { + options = { + searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ], + }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles`; + + (service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed and its size', () => { + const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 5, + }); + (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed and its sizes if given', () => { + const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`; + + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 2, + }); + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpoint}?embed=templateItemOf`; + + (service as any).getFindAllHref( + {}, + null, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; + + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { + const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 4, + }); + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + }); +}); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts new file mode 100644 index 0000000000..f3666b75ee --- /dev/null +++ b/src/app/core/data/base/find-all-data.ts @@ -0,0 +1,112 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { Observable } from 'rxjs'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { PaginatedList } from '../paginated-list.model'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Interface for a data service that list all of its objects. + */ +export interface FindAllData { + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getFindAllHref?(options: FindListOptions, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable; +} + +/** + * A DataService feature to list all objects. + * + * Concrete data services can use this feature by implementing {@link FindAllData} + * and delegating its method to an inner instance of this class. + */ +export class FindAllDataImpl extends BaseDataService implements FindAllData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + let endpoint$: Observable; + const args = []; + + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged(), + ); + + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts new file mode 100644 index 0000000000..d08f1141fc --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -0,0 +1,145 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { FindListOptions } from '../find-list-options.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { IdentifiableDataService } from './identifiable-data.service'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('IdentifiableDataService', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getIDHref', () => { + const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; + const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; + + it('should return endpoint', () => { + const result = (service as any).getIDHref(endpointMock, resourceIdMock); + expect(result).toEqual(endpointMock + '/' + resourceIdMock); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles')); + expect(result).toEqual(expected); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; + const result = (service as any).getIDHref( + endpointMock, + resourceIdMock, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ); + expect(result).toEqual(expected); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/src/app/core/data/base/identifiable-data.service.ts b/src/app/core/data/base/identifiable-data.service.ts new file mode 100644 index 0000000000..904f925765 --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.ts @@ -0,0 +1,83 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { RemoteData } from '../remote-data'; +import { BaseDataService } from './base-data.service'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for the method to construct an ID endpoint. + */ +export type ConstructIdEndpoint = (endpoint: string, resourceID: string) => string; + +/** + * The default method to construct an ID endpoint + */ +export const constructIdEndpointDefault = (endpoint, resourceID) => `${endpoint}/${resourceID}`; + +/** + * A type of data service that deals with objects that have an ID. + * + * The effective endpoint to use for the ID can be adjusted by providing a different {@link ConstructIdEndpoint} method. + * This method is passed as an argument so that it can be set on data service features without having to override them. + */ +export class IdentifiableDataService extends BaseDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + protected constructIdEndpoint: ConstructIdEndpoint = constructIdEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow + * @param endpoint The base endpoint for the type of object + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { + return this.buildHrefFromFindOptions(this.constructIdEndpoint(endpoint, resourceID), {}, [], ...linksToFollow); + } + + /** + * Create an observable for the HREF of a specific object based on its identifier + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.getEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts new file mode 100644 index 0000000000..601188ae7d --- /dev/null +++ b/src/app/core/data/base/patch-data.spec.ts @@ -0,0 +1,180 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable max-classes-per-file */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { PatchDataImpl } from './patch-data'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { Item } from '../../shared/item.model'; +import { compare, Operation } from 'fast-json-patch'; +import { PatchRequest } from '../request.models'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { constructIdEndpointDefault } from './identifiable-data.service'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends PatchDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + ) { + super(undefined, requestService, rdbService, objectCache, halService, comparator, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } +} + +describe('PatchDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let comparator; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + comparator = new DummyChangeAnalyzer() as any; + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + comparator, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('patch', () => { + const dso = { + uuid: 'dso-uuid' + }; + const operations = [ + Object.assign({ + op: 'move', + from: '/1', + path: '/5' + }) as Operation + ]; + + beforeEach((done) => { + service.patch(dso, operations).subscribe(() => { + done(); + }); + }); + + it('should send a PatchRequest', () => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); + }); + }); + + describe('update', () => { + let operations; + let dso; + let dso2; + const name1 = 'random string'; + const name2 = 'another random string'; + beforeEach(() => { + operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; + + dso = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name1 }] + }); + + dso2 = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name2 }] + }); + + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); + spyOn(objectCache, 'addPatch'); + }); + + it('should call addPatch on the object cache with the right parameters when there are differences', () => { + service.update(dso2).subscribe(); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + + it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { + service.update(dso).subscribe(); + expect(objectCache.addPatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts new file mode 100644 index 0000000000..558de928c4 --- /dev/null +++ b/src/app/core/data/base/patch-data.ts @@ -0,0 +1,143 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { Operation } from 'fast-json-patch'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { find, map, mergeMap } from 'rxjs/operators'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { PatchRequest } from '../request.models'; +import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { RestRequestMethod } from '../rest-request-method'; +import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; + + +/** + * Interface for a data service that can patch and update objects. + */ +export interface PatchData { + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable>; + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable>; + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void; + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache?(object: T): Observable; +} + +/** + * A DataService feature to patch and update objects. + * + * Concrete data services can use this feature by implementing {@link PatchData} + * and delegating its method to an inner instance of this class. + * + * Note that this feature requires the object in question to have an ID. + * Make sure to use the same {@link ConstructIdEndpoint} as in the parent data service. + */ +export class PatchDataImpl extends IdentifiableDataService implements PatchData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`PatchDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, object.uuid)), + ); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.createPatchFromCache(object).pipe( + mergeMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href, true, true); + }), + ); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void { + this.requestService.commit(method); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache(object: T): Observable { + const oldVersion$ = this.findByHref(object._links.self.href, true, false); + return oldVersion$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + map((oldVersion: T) => this.comparator.diff(oldVersion, object)), + ); + } +} diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts new file mode 100644 index 0000000000..01b5caea5b --- /dev/null +++ b/src/app/core/data/base/put-data.spec.ts @@ -0,0 +1,108 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { PutDataImpl } from './put-data'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends PutDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('PutDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + // todo: add specs (there were no put specs in original DataService suite!) +}); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts new file mode 100644 index 0000000000..bd2a8d2929 --- /dev/null +++ b/src/app/core/data/base/put-data.ts @@ -0,0 +1,69 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { PutRequest } from '../request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Interface for a data service that can send PUT requests. + */ +export interface PutData { + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable>; +} + +/** + * A DataService feature to send PUT requests. + * + * Concrete data services can use this feature by implementing {@link PutData} + * and delegating its method to an inner instance of this class. + */ +export class PutDataImpl extends BaseDataService implements PutData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const request = new PutRequest(requestId, object._links.self.href, serializedObject); + + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + + this.requestService.send(request); + + return this.rdbService.buildFromRequestUUID(requestId); + } +} diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts new file mode 100644 index 0000000000..7abf26b5b8 --- /dev/null +++ b/src/app/core/data/base/search-data.spec.ts @@ -0,0 +1,146 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { SearchData, SearchDataImpl } from './search-data'; +import createSpyObj = jasmine.createSpyObj; +import { FindListOptions } from '../find-list-options.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; + +/** + * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it + */ +export function testSearchDataImplementation(service: SearchData, methods = ['searchBy', 'getSearchByHref']) { + describe('SearchData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeEach(() => { + (service as any).searchData = createSpyObj('searchData', { + searchBy: 'TEST searchBy', + getSearchByHref: 'TEST getSearchByHref', + }); + }); + + if ('searchBy' in methods) { + it('should handle calls to searchBy', () => { + const out: any = service.searchBy('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST searchBy'); + }); + } + + if ('getSearchByHref' in methods) { + it('should handle calls to getSearchByHref', () => { + const out: any = service.getSearchByHref('searchMethod', OPTIONS, ...FOLLOWLINKS); + + expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('searchMethod', OPTIONS, ...FOLLOWLINKS); + expect(out).toBe('TEST getSearchByHref'); + }); + } + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends SearchDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('SearchDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + // todo: add specs (there were no search specs in original DataService suite!) +}); diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts new file mode 100644 index 0000000000..226db8fe0d --- /dev/null +++ b/src/app/core/data/base/search-data.ts @@ -0,0 +1,145 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { hasNoValue, isNotEmpty } from '../../../shared/empty.util'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { PaginatedList } from '../paginated-list.model'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for method to construct a search endpoint + */ +export type ConstructSearchEndpoint = (href: string, searchMethod: string) => string; + +/** + * Default method to construct a search endpoint + */ +export const constructSearchEndpointDefault = (href: string, searchMethod: string): string => `${href}/search/${searchMethod}`; + +/** + * Interface for a data service that can search for objects. + */ +export interface SearchData { + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getSearchByHref?(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable; +} + +/** + * A DataService feature to search for objects. + * + * Concrete data services can use this feature by implementing {@link SearchData} + * and delegating its method to an inner instance of this class. + */ +export class SearchDataImpl extends BaseDataService implements SearchData { + /** + * @param linkPath + * @param requestService + * @param rdbService + * @param objectCache + * @param halService + * @param responseMsToLive + * @param constructSearchEndpoint an optional method to construct the search endpoint, passed as an argument so it can be + * modified without extending this class. Defaults to `${href}/search/${searchMethod}` + */ + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + private constructSearchEndpoint: ConstructSearchEndpoint = constructSearchEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + if (hasNoValue(constructSearchEndpoint)) { + throw new Error(`SearchDataImpl initialized without a constructSearchEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + + return this.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + let result$: Observable; + const args = []; + + result$ = this.getSearchEndpoint(searchMethod); + + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } + + /** + * Return object search endpoint by given search method + * + * @param searchMethod The search method for the object + */ + private getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map(href => this.constructSearchEndpoint(href, searchMethod)), + ); + } +}