From 4b20b0cb8116a234c22fb48a79200eb0783b57a1 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 23 Aug 2022 17:50:46 +0200 Subject: [PATCH 01/31] force initservices to wait until authentication is no longer blocking --- src/app/core/auth/auth-request.service.ts | 37 ++++++++++++++--------- src/app/core/auth/auth.reducer.ts | 6 ++++ src/modules/app/browser-init.service.ts | 7 +++++ src/modules/app/server-init.service.ts | 12 ++++++-- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 00a94822d3..bcaa5972ac 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap, take } from 'rxjs/operators'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { isNotEmpty } from '../../shared/empty.util'; @@ -26,8 +26,8 @@ export abstract class AuthRequestService { ) { } - protected fetchRequest(request: RestRequest): Observable> { - return this.rdbService.buildFromRequestUUID(request.uuid).pipe( + protected fetchRequest(requestId: string): Observable> { + return this.rdbService.buildFromRequestUUID(requestId).pipe( getFirstCompletedRemoteData(), ); } @@ -37,27 +37,36 @@ export abstract class AuthRequestService { } public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable> { - return this.halService.getEndpoint(this.linkName).pipe( + const requestId = this.requestService.generateRequestId(); + + this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), - map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), - tap((request: PostRequest) => this.requestService.send(request)), - mergeMap((request: PostRequest) => this.fetchRequest(request)), - distinctUntilChanged()); + map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)), + take(1) + ).subscribe((request: PostRequest) => { + this.requestService.send(request); + }); + + return this.fetchRequest(requestId); } public getRequest(method: string, options?: HttpOptions): Observable> { - return this.halService.getEndpoint(this.linkName).pipe( + const requestId = this.requestService.generateRequestId(); + + this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), distinctUntilChanged(), - map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), - tap((request: GetRequest) => this.requestService.send(request)), - mergeMap((request: GetRequest) => this.fetchRequest(request)), - distinctUntilChanged()); - } + map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)), + take(1) + ).subscribe((request: GetRequest) => { + this.requestService.send(request); + }); + return this.fetchRequest(requestId); + } /** * Factory function to create the request object to send. This needs to be a POST client side and * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 6f47a3c20c..acdb8ef812 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -17,6 +17,7 @@ import { import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; +import { StoreActionTypes } from '../../store.actions'; /** * The auth state. @@ -251,6 +252,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut idle: false, }); + case StoreActionTypes.REHYDRATE: + return Object.assign({}, state, { + blocking: true, + }); + default: return state; } diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts index 3980b8bc28..2d49870d58 100644 --- a/src/modules/app/browser-init.service.ts +++ b/src/modules/app/browser-init.service.ts @@ -91,6 +91,13 @@ export class BrowserInitService extends InitService { this.initKlaro(); + // wait for auth to be ready + await this.store.pipe( + select(isAuthenticationBlocking), + distinctUntilChanged(), + find((b: boolean) => b === false) + ).toPromise(); + return true; }; } diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts index 803dc7a75a..fb3539ecfa 100644 --- a/src/modules/app/server-init.service.ts +++ b/src/modules/app/server-init.service.ts @@ -6,7 +6,7 @@ * http://www.dspace.org/license/ */ import { InitService } from '../../app/init.service'; -import { Store } from '@ngrx/store'; +import { Store, select } from '@ngrx/store'; import { AppState } from '../../app/app.reducer'; import { TransferState } from '@angular/platform-browser'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; @@ -20,7 +20,8 @@ import { MetadataService } from '../../app/core/metadata/metadata.service'; import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; import { CSSVariableService } from '../../app/shared/sass-helper/sass-helper.service'; import { ThemeService } from '../../app/shared/theme-support/theme.service'; -import { take } from 'rxjs/operators'; +import { take, distinctUntilChanged, find } from 'rxjs/operators'; +import { isAuthenticationBlocking } from '../../app/core/auth/selectors'; /** * Performs server-side initialization. @@ -66,6 +67,13 @@ export class ServerInitService extends InitService { this.initRouteListeners(); this.themeService.listenForThemeChanges(false); + // wait for auth to be ready + await this.store.pipe( + select(isAuthenticationBlocking), + distinctUntilChanged(), + find((b: boolean) => b === false) + ).toPromise(); + return true; }; } From 8f4b3b58fba9fa228e708543d8fe63e835f35cd7 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 24 Aug 2022 12:54:27 +0200 Subject: [PATCH 02/31] 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)), + ); + } +} From 42a2c3c7e29f82bf1c5ab94e5e241f42bc09bc7d Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Wed, 24 Aug 2022 14:53:46 +0200 Subject: [PATCH 03/31] 93803: Refactor existing data services --- .../collection-metadata.component.spec.ts | 14 +- .../collection-metadata.component.ts | 27 ++- .../breadcrumbs/dso-breadcrumb.resolver.ts | 13 +- .../browse-definition-data.service.spec.ts | 37 +--- .../browse/browse-definition-data.service.ts | 107 ++--------- src/app/core/config/config.service.spec.ts | 10 +- src/app/core/config/config.service.ts | 53 +----- .../submission-accesses-config.service.ts | 6 +- .../config/submission-forms-config.service.ts | 11 +- .../submission-uploads-config.service.ts | 11 +- src/app/core/core.module.ts | 4 + .../data/access-status-data.service.spec.ts | 2 +- .../core/data/access-status-data.service.ts | 23 +-- .../core/data/bitstream-data.service.spec.ts | 2 +- src/app/core/data/bitstream-data.service.ts | 124 ++++++++++-- .../bitstream-format-data.service.spec.ts | 6 +- .../data/bitstream-format-data.service.ts | 84 ++++++-- src/app/core/data/bundle-data.service.spec.ts | 3 - src/app/core/data/bundle-data.service.ts | 58 ++++-- .../core/data/collection-data.service.spec.ts | 2 +- src/app/core/data/collection-data.service.ts | 22 +-- src/app/core/data/comcol-data.service.spec.ts | 2 +- src/app/core/data/comcol-data.service.ts | 166 +++++++++++++++- src/app/core/data/community-data.service.ts | 10 +- .../data/configuration-data.service.spec.ts | 8 - .../core/data/configuration-data.service.ts | 35 +--- .../data/dso-redirect-data.service.spec.ts | 111 +++++------ .../core/data/dso-redirect-data.service.ts | 95 --------- src/app/core/data/dso-redirect.service.ts | 90 +++++++++ .../data/dspace-object-data.service.spec.ts | 8 - .../core/data/dspace-object-data.service.ts | 98 +--------- src/app/core/data/entity-type.service.ts | 72 +++++-- .../core/data/eperson-registration.service.ts | 8 +- .../core/data/external-source.service.spec.ts | 4 +- src/app/core/data/external-source.service.ts | 41 ++-- .../authorization-data.service.spec.ts | 10 +- .../authorization-data.service.ts | 44 +++-- .../feature-data.service.ts | 15 +- .../core/data/href-only-data.service.spec.ts | 8 +- src/app/core/data/href-only-data.service.ts | 40 +--- src/app/core/data/item-data.service.spec.ts | 12 +- src/app/core/data/item-data.service.ts | 141 ++++++++++++-- .../data/item-request-data.service.spec.ts | 2 +- .../core/data/item-request-data.service.ts | 31 +-- .../data/item-template-data.service.spec.ts | 78 ++++---- .../core/data/item-template-data.service.ts | 180 ++++-------------- .../data/metadata-field-data.service.spec.ts | 12 +- .../core/data/metadata-field-data.service.ts | 120 ++++++++++-- .../data/metadata-schema-data.service.spec.ts | 12 +- .../core/data/metadata-schema-data.service.ts | 84 ++++++-- .../persistent-identifier-data.service.ts | 28 +++ .../data/processes/process-data.service.ts | 41 ++-- .../data/processes/script-data.service.ts | 47 +++-- .../data/relationship-type.service.spec.ts | 18 +- .../core/data/relationship-type.service.ts | 118 ++++++------ .../core/data/relationship.service.spec.ts | 8 +- src/app/core/data/relationship.service.ts | 79 +++++--- src/app/core/data/root-data.service.spec.ts | 15 +- src/app/core/data/root-data.service.ts | 82 +------- src/app/core/data/site-data.service.spec.ts | 12 -- src/app/core/data/site-data.service.ts | 44 +++-- .../core/data/version-data.service.spec.ts | 25 +-- src/app/core/data/version-data.service.ts | 50 +++-- .../data/version-history-data.service.spec.ts | 10 +- .../core/data/version-history-data.service.ts | 25 +-- .../core/data/workflow-action-data.service.ts | 22 +-- .../core/eperson/eperson-data.service.spec.ts | 7 +- src/app/core/eperson/eperson-data.service.ts | 128 +++++++++++-- .../core/eperson/group-data.service.spec.ts | 9 +- src/app/core/eperson/group-data.service.ts | 70 ++++++- .../feedback/feedback-data.service.spec.ts | 8 - .../core/feedback/feedback-data.service.ts | 27 ++- src/app/core/orcid/orcid-auth.service.spec.ts | 11 +- src/app/core/orcid/orcid-auth.service.ts | 4 +- .../core/orcid/orcid-history-data.service.ts | 94 +-------- src/app/core/orcid/orcid-queue.service.ts | 73 ++----- .../researcher-profile.service.spec.ts | 68 +++---- .../profile/researcher-profile.service.ts | 180 ++++++++++-------- .../resource-policy.service.spec.ts | 76 +++----- .../resource-policy.service.ts | 100 +++------- src/app/core/shared/search/search.service.ts | 68 ++----- .../statistics/usage-report-data.service.ts | 47 ++--- .../resolver/submission-object.resolver.ts | 10 +- .../submission-cc-license-data.service.ts | 45 +++-- .../submission-cc-license-url-data.service.ts | 67 ++++--- .../submission-object-data.service.ts | 4 +- ...abulary-entry-details.data.service.spec.ts | 16 ++ .../vocabulary-entry-details.data.service.ts | 104 ++++++++++ .../vocabulary.data.service.spec.ts | 14 ++ .../vocabularies/vocabulary.data.service.ts | 70 +++++++ .../vocabularies/vocabulary.service.spec.ts | 33 +--- .../vocabularies/vocabulary.service.ts | 78 +------- .../workflowitem-data.service.spec.ts | 7 +- .../submission/workflowitem-data.service.ts | 75 ++++++-- .../workspaceitem-data.service.spec.ts | 7 +- .../submission/workspaceitem-data.service.ts | 86 +++++++-- .../tasks/claimed-task-data.service.spec.ts | 14 +- .../core/tasks/claimed-task-data.service.ts | 25 +-- .../core/tasks/pool-task-data.service.spec.ts | 15 +- src/app/core/tasks/pool-task-data.service.ts | 25 +-- src/app/core/tasks/tasks.service.spec.ts | 22 +-- src/app/core/tasks/tasks.service.ts | 66 ++++++- .../item-collection-mapper.component.ts | 1 - .../orcid-sync-settings.component.spec.ts | 12 +- .../orcid-sync-settings.component.ts | 4 +- src/app/lookup-by-id/lookup-by-id.module.ts | 4 +- src/app/lookup-by-id/lookup-guard.ts | 4 +- ...ile-page-researcher-form.component.spec.ts | 2 +- .../profile-page-researcher-form.component.ts | 21 +- ...med-task-actions-approve.component.spec.ts | 8 +- .../claimed-task-actions.component.ts | 13 +- ...sk-actions-edit-metadata.component.spec.ts | 8 +- ...imed-task-actions-reject.component.spec.ts | 8 +- ...k-actions-return-to-pool.component.spec.ts | 8 +- .../mydspace-actions-service.factory.ts | 4 +- .../mydspace-actions/mydspace-actions.ts | 11 +- .../mydspace-reloadable-actions.spec.ts | 16 +- .../mydspace-reloadable-actions.ts | 9 +- .../pool-task-actions.component.spec.ts | 10 +- .../pool-task/pool-task-actions.component.ts | 1 + .../item-select/item-select.component.spec.ts | 2 +- .../eperson-group-list.component.ts | 3 +- .../resource-policy-target.resolver.ts | 5 +- 123 files changed, 2487 insertions(+), 2200 deletions(-) delete mode 100644 src/app/core/data/dso-redirect-data.service.ts create mode 100644 src/app/core/data/dso-redirect.service.ts create mode 100644 src/app/core/data/persistent-identifier-data.service.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.spec.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary-entry-details.data.service.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts create mode 100644 src/app/core/submission/vocabularies/vocabulary.data.service.ts diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts index 2b473bf037..79e7a465e1 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts @@ -13,7 +13,7 @@ import { Item } from '../../../core/shared/item.model'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; import { Collection } from '../../../core/shared/collection.model'; import { RequestService } from '../../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; describe('CollectionMetadataComponent', () => { @@ -39,8 +39,8 @@ describe('CollectionMetadataComponent', () => { const itemTemplateServiceStub = jasmine.createSpyObj('itemTemplateService', { findByCollectionID: createSuccessfulRemoteDataObject$(template), - create: createSuccessfulRemoteDataObject$(template), - deleteByCollectionID: observableOf(true), + createByCollectionID: createSuccessfulRemoteDataObject$(template), + delete: observableOf(true), getCollectionEndpoint: observableOf(collectionTemplateHref), }); @@ -91,12 +91,12 @@ describe('CollectionMetadataComponent', () => { describe('deleteItemTemplate', () => { beforeEach(() => { - (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); + (itemTemplateService.delete as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({})); comp.deleteItemTemplate(); }); - it('should call ItemTemplateService.deleteByCollectionID', () => { - expect(itemTemplateService.deleteByCollectionID).toHaveBeenCalledWith(template, 'collection-id'); + it('should call ItemTemplateService.delete', () => { + expect(itemTemplateService.delete).toHaveBeenCalledWith(template.uuid); }); describe('when delete returns a success', () => { @@ -107,7 +107,7 @@ describe('CollectionMetadataComponent', () => { describe('when delete returns a failure', () => { beforeEach(() => { - (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(false)); + (itemTemplateService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); comp.deleteItemTemplate(); }); diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index d4396fce17..8e534a0829 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -7,12 +7,14 @@ import { ItemTemplateDataService } from '../../../core/data/item-template-data.s import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { switchMap } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { map, switchMap } from 'rxjs/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../../core/data/request.service'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { hasValue } from '../../../shared/empty.util'; /** * Component for editing a collection's metadata @@ -65,7 +67,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.create(new Item(), collection.uuid).pipe( + switchMap((collection: Collection) => this.itemTemplateService.createByCollectionID(new Item(), collection.uuid).pipe( getFirstSucceededRemoteDataPayload(), )), ); @@ -83,18 +85,15 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.findByCollectionID(collection.uuid).pipe( - getFirstSucceededRemoteDataPayload(), - )), - ); - combineLatestObservable(collection$, template$).pipe( - switchMap(([collection, template]) => { - return this.itemTemplateService.deleteByCollectionID(template, collection.uuid); - }) + switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)), + getFirstSucceededRemoteDataPayload(), + switchMap((template) => { + return this.itemTemplateService.delete(template.uuid); + }), + getFirstCompletedRemoteData(), + map((response: RemoteData) => hasValue(response) && response.hasSucceeded), ).subscribe((success: boolean) => { if (success) { this.notificationsService.success(null, this.translate.get('collection.edit.template.notifications.delete.success')); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 650bbd3301..8be4e5e099 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -2,23 +2,26 @@ import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DataService } from '../data/data.service'; -import { getRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../shared/dspace-object.model'; import { ChildHALResource } from '../shared/child-hal-resource.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { hasValue } from '../../shared/empty.util'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export abstract class DSOBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService) { + protected constructor( + protected breadcrumbService: DSOBreadcrumbsService, + protected dataService: IdentifiableDataService, + ) { } /** @@ -36,7 +39,7 @@ export abstract class DSOBreadcrumbResolver { let service: BrowseDefinitionDataService; - const dataServiceImplSpy = jasmine.createSpyObj('dataService', { + const findAllDataSpy = jasmine.createSpyObj('findAllData', { findAll: EMPTY, - findByHref: EMPTY, - findAllByHref: EMPTY, - findById: EMPTY, }); - const hrefAll = 'https://rest.api/server/api/discover/browses'; - const hrefSingle = 'https://rest.api/server/api/discover/browses/author'; - const id = 'author'; const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), @@ -21,35 +15,14 @@ describe(`BrowseDefinitionDataService`, () => { ]; beforeEach(() => { - service = new BrowseDefinitionDataService(null, null, null, null, null, null, null, null); - (service as any).dataService = dataServiceImplSpy; + service = new BrowseDefinitionDataService(null, null, null, null); + (service as any).findAllData = findAllDataSpy; }); describe(`findAll`, () => { - it(`should call findAll on DataServiceImpl`, () => { + it(`should call findAll on findAllData`, () => { service.findAll(options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); - }); - }); - - describe(`findByHref`, () => { - it(`should call findByHref on DataServiceImpl`, () => { - service.findByHref(hrefSingle, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findByHref).toHaveBeenCalledWith(hrefSingle, true, false, ...linksToFollow); - }); - }); - - describe(`findAllByHref`, () => { - it(`should call findAllByHref on DataServiceImpl`, () => { - service.findAllByHref(hrefAll, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(hrefAll, options, true, false, ...linksToFollow); - }); - }); - - describe(`findById`, () => { - it(`should call findById on DataServiceImpl`, () => { - service.findAllByHref(id, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow); + expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); }); }); }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 6a27bb3f7a..3c9f6d6983 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,125 +1,40 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { dataService } from '../cache/builders/build-decorators'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { DataService } from '../data/data.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; - - -class DataServiceImpl extends DataService { - protected linkPath = 'browses'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BROWSE_DEFINITION) -export class BrowseDefinitionDataService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; +export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData { + private findAllData: FindAllDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('browses', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.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.dataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns an observable of {@link RemoteData} of an {@link BrowseDefinition}, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of {@link BrowseDefinition} 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 - */ - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of {@link BrowseDefinition}s, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of object we want to retrieve - * @param findListOptions 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 - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * 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> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index be354ddc6f..ead7c7e005 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -9,6 +9,7 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { FindListOptions } from '../data/find-list-options.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; @@ -20,8 +21,10 @@ class TestService extends ConfigService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(requestService, rdbService, null, null, halService, null, null, null, BROWSE); + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(BROWSE, requestService, rdbService, objectCache, halService); } } @@ -45,7 +48,8 @@ describe('ConfigService', () => { return new TestService( requestService, rdbService, - halService + null, + halService, ); } diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 3bc87c8de0..f0b55f5351 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,59 +1,14 @@ -/* eslint-disable max-classes-per-file */ import { Observable } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ConfigObject } from './models/config.model'; import { RemoteData } from '../data/remote-data'; -import { DataService } from '../data/data.service'; -import { Store } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { map } from 'rxjs/operators'; -import { CoreState } from '../core-state.model'; - -class DataServiceImpl extends DataService { - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - super(); - } -} - -export abstract class ConfigService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, this.linkPath); - } +import { BaseDataService } from '../data/base/base-data.service'; +export abstract class ConfigService extends BaseDataService { public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( getFirstCompletedRemoteData(), map((rd: RemoteData) => { if (rd.hasFailed) { @@ -61,7 +16,7 @@ export abstract class ConfigService { } else { return rd; } - }) + }), ); } } diff --git a/src/app/core/config/submission-accesses-config.service.ts b/src/app/core/config/submission-accesses-config.service.ts index 7c2d2046d9..4841e2e39d 100644 --- a/src/app/core/config/submission-accesses-config.service.ts +++ b/src/app/core/config/submission-accesses-config.service.ts @@ -26,14 +26,10 @@ export class SubmissionAccessesConfigService extends ConfigService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionaccessoptions'); + super('submissionaccessoptions', requestService, rdbService, objectCache, halService); } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config.service.ts index 1db5c2fa01..abd3332cae 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config.service.ts @@ -4,11 +4,7 @@ import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_FORMS_TYPE } from './models/config-type'; @@ -16,7 +12,6 @@ import { SubmissionFormsModel } from './models/config-submission-forms.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; @Injectable() @dataService(SUBMISSION_FORMS_TYPE) @@ -24,14 +19,10 @@ export class SubmissionFormsConfigService extends ConfigService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionforms'); + super('submissionforms', requestService, rdbService, objectCache, halService); } public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config.service.ts index 8ad17749bd..b166b895ac 100644 --- a/src/app/core/config/submission-uploads-config.service.ts +++ b/src/app/core/config/submission-uploads-config.service.ts @@ -6,16 +6,11 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. @@ -26,14 +21,10 @@ export class SubmissionUploadsConfigService extends ConfigService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionuploads'); + super('submissionuploads', requestService, rdbService, objectCache, halService); } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index b16930e819..80576c03b7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -178,6 +178,8 @@ import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; import { OrcidQueue } from './orcid/model/orcid-queue.model'; import { OrcidHistory } from './orcid/model/orcid-history.model'; import { OrcidAuthService } from './orcid/orcid-auth.service'; +import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; +import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -300,6 +302,8 @@ const PROVIDERS = [ FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, VocabularyService, + VocabularyDataService, + VocabularyEntryDetailsDataService, VocabularyTreeviewService, SequenceService, GroupDataService, diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index d81b9384f3..18b8cb5d65 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -76,6 +76,6 @@ describe('AccessStatusDataService', () => { }); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); - service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); + service = new AccessStatusDataService(requestService, rdbService, objectCache, halService); } }); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index 09843fac9b..2b1dfd319a 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -1,38 +1,27 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; import { RequestService } from './request.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { CoreState } from '../core-state.model'; import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { Item } from '../shared/item.model'; +import { BaseDataService } from './base/base-data.service'; @Injectable() @dataService(ACCESS_STATUS) -export class AccessStatusDataService extends DataService { - - protected linkPath = 'accessStatus'; +export class AccessStatusDataService extends BaseDataService { constructor( - protected comparator: DefaultChangeAnalyzer, - protected halService: HALEndpointService, - protected http: HttpClient, - protected notificationsService: NotificationsService, - protected objectCache: ObjectCacheService, - protected rdbService: RemoteDataBuildService, protected requestService: RequestService, - protected store: Store, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, ) { - super(); + super('accessStatus', requestService, rdbService, objectCache, halService); } /** diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index df170397f8..07d02f84ae 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -47,7 +47,7 @@ describe('BitstreamDataService', () => { }); rdbService = getMockRemoteDataBuildService(); - service = new BitstreamDataService(requestService, rdbService, null, objectCache, halService, null, null, null, null, bitstreamFormatService); + service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null); }); describe('when updating the bitstream\'s format', () => { diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 16f2cc16c2..ab0a4c301c 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,10 +1,8 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -15,8 +13,6 @@ import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { BundleDataService } from './bundle-data.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { PutRequest } from './request.models'; @@ -28,36 +24,43 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { PageInfo } from '../shared/page-info.model'; import { RequestParam } from '../cache/models/request-param.model'; import { sendRequest } from '../shared/request.operators'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RestRequestMethod } from './rest-request-method'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NoContent } from '../shared/NoContent.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; /** * A service to retrieve {@link Bitstream}s from the REST API */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BITSTREAM) -export class BitstreamDataService extends DataService { - - /** - * The HAL path to the bitstream endpoint - */ - protected linkPath = 'bitstreams'; +export class BitstreamDataService extends IdentifiableDataService implements SearchData, PatchData, DeleteData { + private searchData: SearchDataImpl; + private patchData: PatchDataImpl; + private deleteData: DeleteDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, protected bundleService: BundleDataService, - protected bitstreamFormatService: BitstreamFormatDataService + protected bitstreamFormatService: BitstreamFormatDataService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, ) { - super(); + super('bitstreams', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -180,8 +183,89 @@ export class BitstreamDataService extends DataService { hrefObs, useCachedVersionIfAvailable, reRequestOnStale, - ...linksToFollow + ...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 + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * 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 + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * 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 + */ + public patch(object: Bitstream, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * 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 + */ + public update(object: Bitstream): Observable> { + return this.patchData.update(object); + } + + /** + * 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> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index 30ef79ee6d..d1c48ab82e 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -50,8 +50,6 @@ describe('BitstreamFormatDataService', () => { } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; let rd; let rdbService: RemoteDataBuildService; @@ -65,12 +63,10 @@ describe('BitstreamFormatDataService', () => { return new BitstreamFormatDataService( requestService, rdbService, - store, objectCache, halService, notificationsService, - http, - comparator + store, ); } diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 1af3db8103..72d14fbf68 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,13 +1,8 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { - BitstreamFormatsRegistryDeselectAction, - BitstreamFormatsRegistryDeselectAllAction, - BitstreamFormatsRegistrySelectAction -} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; @@ -18,40 +13,52 @@ import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RemoteData } from './remote-data'; import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { sendRequest } from '../shared/request.operators'; import { CoreState } from '../core-state.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { NoContent } from '../shared/NoContent.model'; const bitstreamFormatsStateSelector = createSelector( coreSelector, - (state: CoreState) => state.bitstreamFormats + (state: CoreState) => state.bitstreamFormats, +); +const selectedBitstreamFormatSelector = createSelector( + bitstreamFormatsStateSelector, + (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats, ); -const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector, - (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats); /** * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ @Injectable() @dataService(BITSTREAM_FORMAT) -export class BitstreamFormatDataService extends DataService { +export class BitstreamFormatDataService extends IdentifiableDataService implements FindAllData, DeleteData { protected linkPath = 'bitstreamformats'; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected store: Store, + ) { + super('bitstreamformats', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -60,7 +67,7 @@ export class BitstreamFormatDataService extends DataService { */ public getUpdateEndpoint(formatId: string): Observable { return this.getBrowseEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, formatId)) + map((endpoint: string) => this.getIDHref(endpoint, formatId)), ); } @@ -147,4 +154,47 @@ export class BitstreamFormatDataService extends DataService { findByBitstream(bitstream: Bitstream): Observable> { return this.findByHref(bitstream._links.format.href); } + + /** + * 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>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * 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> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index 12eec9e33d..80bf7c281c 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -64,9 +64,6 @@ describe('BundleDataService', () => { store, objectCache, halService, - notificationsService, - http, - comparator, ); } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index fa5ee51b45..bc559b4b65 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,10 +1,7 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -13,8 +10,6 @@ import { Bundle } from '../shared/bundle.model'; import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { GetRequest } from './request.models'; @@ -22,30 +17,35 @@ import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request-entry-state.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RestRequestMethod } from './rest-request-method'; +import { Operation } from 'fast-json-patch'; /** * A service to retrieve {@link Bundle}s from the REST API */ @Injectable( - {providedIn: 'root'} + { providedIn: 'root' }, ) @dataService(BUNDLE) -export class BundleDataService extends DataService { - protected linkPath = 'bundles'; - protected bitstreamsEndpoint = 'bitstreams'; +export class BundleDataService extends IdentifiableDataService implements PatchData { + private bitstreamsEndpoint = 'bitstreams'; + + private patchData: PatchDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected comparator: DSOChangeAnalyzer, + ) { + super('bundles', requestService, rdbService, objectCache, halService); + + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -133,7 +133,7 @@ export class BundleDataService extends DataService { const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request, true); @@ -141,4 +141,30 @@ export class BundleDataService extends DataService { return this.rdbService.buildList(hrefObs, ...linksToFollow); } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * 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 + */ + public patch(object: Bundle, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * 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 + */ + public update(object: Bundle): Observable> { + return this.patchData.update(object); + } } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 031e5ecf47..6d43d5475c 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -201,7 +201,7 @@ describe('CollectionDataService', () => { notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, null, null, objectCache, halService, notificationsService, null, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index c243b49d3f..c9ebc0e74f 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,5 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; @@ -33,30 +32,27 @@ import { import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; import { RestRequest } from './rest-request.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { Community } from '../shared/community.model'; @Injectable() @dataService(COLLECTION) export class CollectionDataService extends ComColDataService { - protected linkPath = 'collections'; protected errorTitle = 'collection.source.update.notifications.error.title'; protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, - protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, - protected comparator: DSOChangeAnalyzer, - protected translate: TranslateService + protected communityDataService: CommunityDataService, + protected translate: TranslateService, ) { - super(); + super('collections', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } /** @@ -289,10 +285,10 @@ export class CollectionDataService extends ComColDataService { protected getScopeCommunityHref(options: FindListOptions) { - return this.cds.getEndpoint().pipe( - map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), + return this.communityDataService.getEndpoint().pipe( + map((endpoint: string) => this.communityDataService.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), - take(1) + take(1), ); } } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index dffc97f294..758cbad97a 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -47,7 +47,7 @@ class TestService extends ComColDataService { protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { - super(); + super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } protected getFindByParentHref(parentUUID: string): Observable { diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 01cd18df0c..1b1ac3b27b 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -4,7 +4,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { HALLink } from '../shared/hal-link.model'; -import { DataService } from './data.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -17,11 +16,44 @@ import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; import { URLCombiner } from '../url-combiner/url-combiner'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { RestRequestMethod } from './rest-request-method'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -export abstract class ComColDataService extends DataService { - protected abstract objectCache: ObjectCacheService; - protected abstract halService: HALEndpointService; - protected abstract bitstreamDataService: BitstreamDataService; +export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private searchData: SearchData; + private patchData: PatchData; + private deleteData: DeleteData; + + protected constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, + protected bitstreamDataService: BitstreamDataService, + ) { + super(linkPath, requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } /** * Get the scoped endpoint URL by fetching the object with @@ -129,4 +161,128 @@ export abstract class ComColDataService extend const parentCommunity = dso._links.parentCommunity; return isNotEmpty(parentCommunity) ? parentCommunity.href : null; } + + + /** + * 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> { + return this.createData.create(object, ...params); + } + + /** + * 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 + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(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 + */ + public getFindAllHref(options?: FindListOptions, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.findAllData.getFindAllHref(options, linkPath, ...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 + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * 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 + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * 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 + */ + public patch(object: T, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * 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 + */ + public update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * 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 + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 903d9bc79c..3062b15b1e 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,7 +1,5 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -19,27 +17,23 @@ import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { isNotEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; @Injectable() @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { - protected linkPath = 'communities'; protected topLinkPath = 'search/top'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } getEndpoint() { diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts index 7077f098e0..7fe69c16e5 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -5,8 +5,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { ConfigurationDataService } from './configuration-data.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; @@ -44,18 +42,12 @@ describe('ConfigurationDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new ConfigurationDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index c8241aa9c7..ed62ee4933 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,55 +1,30 @@ /* eslint-disable max-classes-per-file */ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; -import { CoreState } from '../core-state.model'; - -class DataServiceImpl extends DataService { - protected linkPath = 'properties'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; @Injectable() @dataService(CONFIG_PROPERTY) /** * Data Service responsible for retrieving Configuration properties */ -export class ConfigurationDataService { - protected linkPath = 'properties'; - private dataService: DataServiceImpl; +export class ConfigurationDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('properties', requestService, rdbService, objectCache, halService); } /** @@ -57,6 +32,6 @@ export class ConfigurationDataService { * @param name */ findByPropertyName(name: string): Observable> { - return this.dataService.findById(name); + return this.findById(name); } } diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts index 3f3a799e45..6bfd8dbd2e 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect-data.service.spec.ts @@ -1,22 +1,18 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectDataService } from './dso-redirect-data.service'; +import { DsoRedirectService } from './dso-redirect.service'; import { GetRequest, IdentifierType } from './request.models'; import { RequestService } from './request.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; -import { CoreState } from '../core-state.model'; -describe('DsoRedirectDataService', () => { +describe('DsoRedirectService', () => { let scheduler: TestScheduler; - let service: DsoRedirectDataService; + let service: DsoRedirectService; let halService: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; @@ -29,10 +25,6 @@ describe('DsoRedirectDataService', () => { const requestHandleURL = `https://rest.api/rest/api/pid/find?id=${encodedHandle}`; const requestUUIDURL = `https://rest.api/rest/api/pid/find?id=${dsoUUID}`; const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; - const store = {} as Store; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; const objectCache = {} as ObjectCacheService; beforeEach(() => { @@ -59,20 +51,16 @@ describe('DsoRedirectDataService', () => { a: remoteData }) }); - service = new DsoRedirectDataService( + service = new DsoRedirectService( requestService, rdbService, - store, objectCache, halService, - notificationsService, - http, - comparator, - router + router, ); }); - describe('findById', () => { + describe('findByIdAndIDType', () => { it('should call HALEndpointService with the path to the pid endpoint', () => { scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.flush(); @@ -141,7 +129,7 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith([remoteData.payload.type + 's/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/collections/' + remoteData.payload.uuid]); }); it('should navigate to communities route', () => { @@ -150,55 +138,58 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/communities/' + remoteData.payload.uuid]); }); }); - describe('getIDHref', () => { - it('should return endpoint', () => { - const result = (service as any).getIDHref(pidLink, dsoUUID); - expect(result).toEqual(requestUUIDURL); - }); + describe('DataService', () => { // todo: should only test the id/uuid interpolation thingy + describe('getIDHref', () => { // todo: should be able to move this up to IdentifiableDataService? + it('should return endpoint', () => { + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID); + expect(result).toEqual(requestUUIDURL); + }); - it('should include single linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles')); - expect(result).toEqual(expected); - }); + it('should include single linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles')); + expect(result).toEqual(expected); + }); - it('should include multiple linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); - expect(result).toEqual(expected); - }); + it('should include multiple linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${requestUUIDURL}&embed=templateItemOf`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ); - expect(result).toEqual(expected); - }); + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${requestUUIDURL}&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ); + expect(result).toEqual(expected); + }); - it('should include nested linksToFollow 3lvl', () => { - const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('owningCollection', - {}, - followLink('itemtemplate', + it('should include nested linksToFollow 3lvl', () => { + const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink( + 'owningCollection', {}, - followLink('relationships') - ) - ) - ); - expect(result).toEqual(expected); + followLink( + 'itemtemplate', + {}, + followLink('relationships'), + ), + ), + ); + expect(result).toEqual(expected); + }); }); }); - }); diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts deleted file mode 100644 index 6270689f03..0000000000 --- a/src/app/core/data/dso-redirect-data.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RemoteData } from './remote-data'; -import { IdentifierType } from './request.models'; -import { RequestService } from './request.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { Item } from '../shared/item.model'; -import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; -import { CoreState } from '../core-state.model'; - -@Injectable() -export class DsoRedirectDataService extends DataService { - - // Set the default link path to the identifier lookup endpoint. - protected linkPath = 'pid'; - private uuidEndpoint = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - private router: Router) { - super(); - } - - setLinkPath(identifierType: IdentifierType) { - // The default 'pid' endpoint for identifiers does not support uuid lookups. - // For uuid lookups we need to change the linkPath. - if (identifierType === IdentifierType.UUID) { - this.linkPath = this.uuidEndpoint; - } - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - // Supporting both identifier (pid) and uuid (dso) endpoints - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`) - .replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } - - findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { - this.setLinkPath(identifierType); - return this.findById(id).pipe( - getFirstCompletedRemoteData(), - tap((response) => { - if (response.hasSucceeded) { - const dso = response.payload; - const uuid = dso.uuid; - if (hasValue(uuid)) { - let newRoute = this.getEndpointFromDSOType(response.payload.type); - if (dso.type.startsWith('item')) { - newRoute = getItemPageRoute(dso as Item); - } else if (hasValue(newRoute)) { - newRoute += '/' + uuid; - } - if (hasValue(newRoute)) { - this.router.navigate([newRoute]); - } - } - } - }) - ); - } - // Is there an existing method somewhere else that converts dso type to route? - getEndpointFromDSOType(dsoType: string): string { - // Are there other types to consider? - if (dsoType.startsWith('item')) { - return 'items'; - } else if (dsoType.startsWith('community')) { - return 'communities'; - } else if (dsoType.startsWith('collection')) { - return 'collections'; - } else { - return ''; - } - } -} diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts new file mode 100644 index 0000000000..9e7277f2b1 --- /dev/null +++ b/src/app/core/data/dso-redirect.service.ts @@ -0,0 +1,90 @@ +/** + * 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 { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteData } from './remote-data'; +import { IdentifierType } from './request.models'; +import { RequestService } from './request.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { getDSORoute } from '../../app-routing-paths'; + +const ID_ENDPOINT = 'pid'; +const UUID_ENDPOINT = 'dso'; + +class DsoByIdOrUUIDService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super( + undefined, requestService, rdbService, objectCache, halService, undefined, + // interpolate id/uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?id}/, `?id=${resourceID}`) + .replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); + } + + /** + * The default 'pid' endpoint for identifiers does not support uuid lookups. + * For uuid lookups we need to change the linkPath. + * @param identifierType + */ + setLinkPath(identifierType: IdentifierType) { + if (identifierType === IdentifierType.UUID) { + this.linkPath = UUID_ENDPOINT; + } else { + this.linkPath = ID_ENDPOINT; + } + } +} + +@Injectable() +export class DsoRedirectService { + private dataService: DsoByIdOrUUIDService; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + private router: Router, + ) { + this.dataService = new DsoByIdOrUUIDService(requestService, rdbService, objectCache, halService); + } + + findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { + this.dataService.setLinkPath(identifierType); + return this.dataService.findById(id).pipe( + getFirstCompletedRemoteData(), + tap((response) => { + if (response.hasSucceeded) { + const dso = response.payload; + if (hasValue(dso.uuid)) { + let newRoute = getDSORoute(dso); + if (hasValue(newRoute)) { + this.router.navigate([newRoute]); + } + } + } + }) + ); + } +} diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 4b3fafa73a..0f167ea47e 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -7,8 +7,6 @@ import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -42,18 +40,12 @@ describe('DSpaceObjectDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new DSpaceObjectDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index ae0d525281..7ab29506aa 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,106 +1,28 @@ -/* eslint-disable max-classes-per-file */ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { PaginatedList } from './paginated-list.model'; -import { CoreState } from '../core-state.model'; -import { FindListOptions } from './find-list-options.model'; - -class DataServiceImpl extends DataService { - protected linkPath = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - super(); - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; @Injectable() @dataService(DSPACE_OBJECT) -export class DSpaceObjectDataService { - protected linkPath = 'dso'; - private dataService: DataServiceImpl; - +export class DSpaceObjectDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super( + 'dso', requestService, rdbService, objectCache, halService, undefined, + // interpolate uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); } - - /** - * 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> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - - } - /** - * 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 - * @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, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} 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 object we want to retrieve - * @param findListOptions 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 - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - } diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index d08e6d28e7..0acb4adb03 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -1,13 +1,8 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; @@ -17,27 +12,30 @@ import { PaginatedList } from './paginated-list.model'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { RelationshipTypeService } from './relationship-type.service'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; /** * Service handling all ItemType requests */ @Injectable() -export class EntityTypeService extends DataService { +export class EntityTypeService extends BaseDataService implements FindAllData, SearchData { + private findAllData: FindAllData; + private searchData: SearchDataImpl; - protected linkPath = 'entitytypes'; + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected relationshipTypeService: RelationshipTypeService, + ) { + super('entitytypes', requestService, rdbService, objectCache, halService); - constructor(protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, - protected objectCache: ObjectCacheService, - protected notificationsService: NotificationsService, - protected relationshipTypeService: RelationshipTypeService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getBrowseEndpoint(options, linkPath?: string): Observable { @@ -158,7 +156,43 @@ export class EntityTypeService extends DataService { return this.halService.getEndpoint(this.linkPath).pipe( take(1), switchMap((endPoint: string) => - this.findByHref(endPoint + '/label/' + label)) + this.findByHref(endPoint + '/label/' + label)), ); } + + /** + * 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 + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * 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>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 989a401733..3b033f693a 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -13,11 +13,9 @@ import { RegistrationResponseParsingService } from './registration-response-pars import { RemoteData } from './remote-data'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -@Injectable( - { - providedIn: 'root', - } -) +@Injectable({ + providedIn: 'root', +}) /** * Service that will register a new email address and request a token */ diff --git a/src/app/core/data/external-source.service.spec.ts b/src/app/core/data/external-source.service.spec.ts index 59226197d1..8a547b69f9 100644 --- a/src/app/core/data/external-source.service.spec.ts +++ b/src/app/core/data/external-source.service.spec.ts @@ -48,9 +48,9 @@ describe('ExternalSourceService', () => { buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) }); halService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf('external-sources-REST-endpoint') + getEndpoint: observableOf('external-sources-REST-endpoint'), }); - service = new ExternalSourceService(requestService, rdbService, undefined, undefined, halService, undefined, undefined, undefined); + service = new ExternalSourceService(requestService, rdbService, undefined, halService); } beforeEach(() => { diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index 6ea7e07d28..434b563191 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -1,13 +1,9 @@ import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; import { ExternalSource } from '../shared/external-source.model'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -15,28 +11,27 @@ import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; /** * A service handling all external source requests */ @Injectable() -export class ExternalSourceService extends DataService { - protected linkPath = 'externalsources'; +export class ExternalSourceService extends IdentifiableDataService implements SearchData { + private searchData: SearchData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + ) { + super('externalsources', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -75,10 +70,28 @@ export class ExternalSourceService extends DataService { isNotEmptyOperator(), distinctUntilChanged(), map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint), - take(1) + take(1), ); // TODO create a dedicated ExternalSourceEntryDataService and move this entire method to it. Then the "as any"s won't be necessary return this.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; } + + /** + * 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 + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return undefined; + } } diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index df46d3f0a1..38b71a5c46 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -1,6 +1,5 @@ import { AuthorizationDataService } from './authorization-data.service'; import { SiteDataService } from '../site-data.service'; -import { AuthService } from '../../auth/auth.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { of as observableOf } from 'rxjs'; @@ -16,7 +15,6 @@ import { FindListOptions } from '../find-list-options.model'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; let siteService: SiteDataService; - let authService: AuthService; let site: Site; let ePerson: EPerson; @@ -37,13 +35,9 @@ describe('AuthorizationDataService', () => { uuid: 'test-eperson' }); siteService = jasmine.createSpyObj('siteService', { - find: observableOf(site) + find: observableOf(site), }); - authService = { - isAuthenticated: () => observableOf(true), - getAuthenticatedUserFromStore: () => observableOf(ePerson) - } as AuthService; - service = new AuthorizationDataService(requestService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, authService, siteService); + service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService); } beforeEach(() => { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index f27919844d..85df98f399 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -2,17 +2,11 @@ import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; import { AUTHORIZATION } from '../../shared/authorization.resource-type'; import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Authorization } from '../../shared/authorization.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; -import { AuthService } from '../../auth/auth.service'; import { SiteDataService } from '../site-data.service'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteData } from '../remote-data'; @@ -24,31 +18,31 @@ import { AuthorizationSearchParams } from './authorization-search-params'; import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; import { getFirstCompletedRemoteData } from '../../shared/operators'; -import { CoreState } from '../../core-state.model'; import { FindListOptions } from '../find-list-options.model'; +import { BaseDataService } from '../base/base-data.service'; +import { SearchData, SearchDataImpl } from '../base/search-data'; /** * A service to retrieve {@link Authorization}s from the REST API */ @Injectable() @dataService(AUTHORIZATION) -export class AuthorizationDataService extends DataService { +export class AuthorizationDataService extends BaseDataService implements SearchData { protected linkPath = 'authorizations'; protected searchByObjectPath = 'object'; + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected authService: AuthService, - protected siteService: SiteDataService + protected siteService: SiteDataService, ) { - super(); + super('authorizations', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -130,7 +124,25 @@ export class AuthorizationDataService extends DataService { params.push(new RequestParam('eperson', ePersonUuid)); } return Object.assign(new FindListOptions(), options, { - searchParams: [...params] + searchParams: [...params], }); } + + /** + * 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>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index cbe8356660..bed6cd114d 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,36 +1,27 @@ import { Injectable } from '@angular/core'; import { FEATURE } from '../../shared/feature.resource-type'; import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Feature } from '../../shared/feature.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; -import { CoreState } from '../../core-state.model'; +import { BaseDataService } from '../base/base-data.service'; /** * A service to retrieve {@link Feature}s from the REST API */ @Injectable() @dataService(FEATURE) -export class FeatureDataService extends DataService { +export class FeatureDataService extends BaseDataService { protected linkPath = 'features'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('features', requestService, rdbService, objectCache, halService); } } diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index 64c451837d..ed8c91399d 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,8 @@ import { HrefOnlyDataService } from './href-only-data.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { DataService } from './data.service'; import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -15,12 +15,12 @@ describe(`HrefOnlyDataService`, () => { href = 'https://rest.api/server/api/core/items/de7fa215-4a25-43a7-a4d7-17534a09fdfc'; followLinks = [ followLink('link1'), followLink('link2') ]; findListOptions = new FindListOptions(); - service = new HrefOnlyDataService(null, null, null, null, null, null, null, null); + service = new HrefOnlyDataService(null, null, null, null); }); it(`should instantiate a private DataService`, () => { expect((service as any).dataService).toBeDefined(); - expect((service as any).dataService).toBeInstanceOf(DataService); + expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); describe(`findByHref`, () => { @@ -28,7 +28,7 @@ describe(`HrefOnlyDataService`, () => { spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); }); - it(`should delegate to findByHref on the internal DataService`, () => { + it(`should forward to findByHref on the internal DataService`, () => { service.findByHref(href, false, false, ...followLinks); expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); }); diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 60c225cb34..ba5abe9340 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,13 +1,7 @@ -/* eslint-disable max-classes-per-file */ -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -18,25 +12,8 @@ import { PaginatedList } from './paginated-list.model'; import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; import { LICENSE } from '../shared/license.resource-type'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; - -class DataServiceImpl extends DataService { - // linkPath isn't used if we're only searching by href. - protected linkPath = undefined; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { BaseDataService } from './base/base-data.service'; /** * A DataService with only findByHref methods. Its purpose is to be used for resources that don't @@ -46,24 +23,25 @@ class DataServiceImpl extends DataService { * an @dataService annotation can be added for any number of these resource types */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(VOCABULARY_ENTRY) @dataService(ITEM_TYPE) @dataService(LICENSE) export class HrefOnlyDataService { - private dataService: DataServiceImpl; + /** + * Not all BaseDataService methods should be exposed, so + * @private + */ + private dataService: BaseDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, objectCache, halService, notificationsService, http, comparator); + ) { + this.dataService = new BaseDataService(undefined, requestService, rdbService, objectCache, halService); } /** diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index a4ed9f882f..01ccfe8c3b 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -21,7 +21,7 @@ import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-serv describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; - let bs: BrowseService; + let browseService: BrowseService; const requestService = Object.assign(getMockRequestService(), { generateRequestId(): string { return scopeID; @@ -78,14 +78,12 @@ describe('ItemDataService', () => { return new ItemDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, - bundleService + browseService, + bundleService, ); } @@ -95,7 +93,7 @@ describe('ItemDataService', () => { }); it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => { - bs = initMockBrowseService(true); + browseService = initMockBrowseService(true); service = initTestService(); const result = service.getBrowseEndpoint(options); @@ -106,7 +104,7 @@ describe('ItemDataService', () => { describe('if the dc.date.issue browse isn\'t configured for items', () => { beforeEach(() => { - bs = initMockBrowseService(false); + browseService = initMockBrowseService(false); service = initTestService(); }); it('should throw an error', () => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index cb5d7a3d57..ecc9e0e502 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,6 +1,13 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +/** + * 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 { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; @@ -16,12 +23,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; import { URLCombiner } from '../url-combiner/url-combiner'; - -import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { DeleteRequest, GetRequest, PostRequest, PutRequest} from './request.models'; +import { DeleteRequest, GetRequest, PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bundle } from '../shared/bundle.model'; @@ -34,27 +39,41 @@ import { ResponseParsingService } from './parsing.service'; import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; import { sendRequest } from '../shared/request.operators'; import { RestRequest } from './rest-request.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { ConstructIdEndpoint, IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { RestRequestMethod } from './rest-request-method'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { RequestParam } from '../cache/models/request-param.model'; -@Injectable() -@dataService(ITEM) -export class ItemDataService extends DataService { - protected linkPath = 'items'; +/** + * An abstract service for CRUD operations on Items + * Doesn't specify an endpoint because multiple endpoints support Item-like functionality (e.g. items, itemtemplates) + * Extend this class to implement data services for Items + */ +export abstract class BaseItemDataService extends IdentifiableDataService implements CreateData, PatchData, DeleteData { + private createData: CreateData; + private patchData: PatchData; + private deleteData: DeleteData; - constructor( + protected constructor( + protected linkPath, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService + protected browseService: BrowseService, + protected bundleService: BundleDataService, + protected constructIdEndpoint: ConstructIdEndpoint = (endpoint, resourceID) => `${endpoint}/${resourceID}`, ) { - super(); + super(linkPath, requestService, rdbService, objectCache, halService, undefined, constructIdEndpoint); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -69,10 +88,11 @@ export class ItemDataService extends DataService { if (options.sort && options.sort.field) { field = options.sort.field; } - return this.bs.getBrowseURLFor(field, linkPath).pipe( + return this.browseService.getBrowseURLFor(field, linkPath).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), - distinctUntilChanged(),); + distinctUntilChanged(), + ); } /** @@ -84,7 +104,7 @@ export class ItemDataService extends DataService { public getMappedCollectionsEndpoint(itemId: string, collectionId?: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`) + map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`), ); } @@ -219,7 +239,7 @@ export class ItemDataService extends DataService { public getMoveItemEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection`) + map((endpoint: string) => `${endpoint}/owningCollection`), ); } @@ -299,4 +319,85 @@ export class ItemDataService extends DataService { this.requestService.setStaleByHrefSubstring('item/' + itemUUID); } + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * 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 + */ + public patch(object: Item, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * 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 + */ + public update(object: Item): Observable> { + return this.patchData.update(object); + } + + /** + * 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 + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * 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 + */ + public create(object: Item, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + +} + +/** + * A service for CRUD operations on Items + */ +@Injectable() +@dataService(ITEM) +export class ItemDataService extends BaseItemDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, + protected bundleService: BundleDataService, + ) { + super('items', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); + } } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index 0d99ca5cd4..a5d1872510 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -35,7 +35,7 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null); + service = new ItemRequestDataService(requestService, rdbService, null, halService); }); describe('requestACopy', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 2bab0b304f..ff6025f7ac 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -9,40 +9,27 @@ import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { ItemRequest } from '../shared/item-request.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DataService } from './data.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { HttpHeaders } from '@angular/common/http'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { CoreState } from '../core-state.model'; import { sendRequest } from '../shared/request.operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint */ -@Injectable( - { - providedIn: 'root', - } -) -export class ItemRequestDataService extends DataService { - - protected linkPath = 'itemrequests'; - +@Injectable({ + providedIn: 'root', +}) +export class ItemRequestDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, ) { - super(); + super('itemrequests', requestService, rdbService, objectCache, halService); } getItemRequestEndpoint(): Observable { @@ -124,9 +111,9 @@ export class ItemRequestDataService extends DataService { suggestOpenAccess, }), options); }), - sendRequest(this.requestService)).subscribe(); + sendRequest(this.requestService), + ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); } - } diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 4b8aa362ba..b73287f50f 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -8,17 +8,17 @@ import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { CollectionDataService } from './collection-data.service'; import { RestRequestMethod } from './rest-request-method'; import { Item } from '../shared/item.model'; import { RestRequest } from './rest-request.model'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; +import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { let service: ItemTemplateDataService; - let itemService: any; + let byCollection: any; const item = new Item(); const collectionEndpoint = 'https://rest.api/core/collections/4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -47,14 +47,14 @@ describe('ItemTemplateDataService', () => { } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; - const bs = {} as BrowseService; + const browseService = {} as BrowseService; const objectCache = { getObjectBySelfLink(self) { return observableOf({}); }, addPatch(self, operations) { // Do nothing - } + }, } as any; const halEndpointService = { getEndpoint(linkPath: string): Observable { @@ -62,7 +62,6 @@ describe('ItemTemplateDataService', () => { } } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; const comparator = { diff(first, second) { return [{}]; @@ -78,60 +77,61 @@ describe('ItemTemplateDataService', () => { service = new ItemTemplateDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, + browseService, undefined, - collectionService + collectionService, ); - itemService = (service as any).dataService; + byCollection = (service as any).byCollection; } beforeEach(() => { initTestService(); }); - describe('commitUpdates', () => { - it('should call commitUpdates on the item service implementation', () => { - spyOn(itemService, 'commitUpdates'); - service.commitUpdates(); - expect(itemService.commitUpdates).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should call update on the item service implementation', () => { - spyOn(itemService, 'update'); - service.update(item); - expect(itemService.update).toHaveBeenCalled(); - }); - }); - describe('findByCollectionID', () => { - it('should call findByCollectionID on the item service implementation', () => { - spyOn(itemService, 'findByCollectionID'); + it('should call findByCollectionID on the collection-based data service', () => { + spyOn(byCollection, 'findById'); service.findByCollectionID(scopeID); - expect(itemService.findByCollectionID).toHaveBeenCalled(); + expect(byCollection.findById).toHaveBeenCalled(); }); }); - describe('create', () => { - it('should call createTemplate on the item service implementation', () => { - spyOn(itemService, 'createTemplate'); - service.create(item, scopeID); - expect(itemService.createTemplate).toHaveBeenCalled(); + describe('createByCollectionID', () => { + it('should call createTemplate on the collection-based data service', () => { + spyOn(byCollection, 'createTemplate'); + service.createByCollectionID(item, scopeID); + expect(byCollection.createTemplate).toHaveBeenCalledWith(item, scopeID); }); }); - describe('deleteByCollectionID', () => { - it('should call deleteByCollectionID on the item service implementation', () => { - spyOn(itemService, 'deleteByCollectionID'); - service.deleteByCollectionID(item, scopeID); - expect(itemService.deleteByCollectionID).toHaveBeenCalled(); + describe('byCollection', () => { + beforeEach(() => { + byCollection.createData = createSpyObj('createData', { + createOnEndpoint: 'TEST createOnEndpoint', + }); + }); + + describe('getIDHrefObs', () => { + it('should point to the Item template of a given Collection', () => { + expect(byCollection.getIDHrefObs(scopeID)).toBeObservable(cold('a', { a: jasmine.stringMatching(`/collections/${scopeID}/itemtemplate`) })); + }); + }); + + describe('createTemplate', () => { + it('should forward to CreateDataImpl.createOnEndpoint', () => { + spyOn(byCollection, 'getIDHrefObs').and.returnValue('TEST getIDHrefObs'); + + const out = byCollection.createTemplate(item, scopeID); + + expect(byCollection.getIDHrefObs).toHaveBeenCalledWith(scopeID); + expect(byCollection.createData.createOnEndpoint).toHaveBeenCalledWith(item, 'TEST getIDHrefObs'); + expect(out).toBe('TEST createOnEndpoint'); + }); }); }); }); + diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index fd9f7de031..634c966dba 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,147 +1,62 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { ItemDataService } from './item-data.service'; -import { UpdateDataService } from './update-data.service'; +import { BaseItemDataService } from './item-data.service'; import { Item } from '../shared/item.model'; -import { RestRequestMethod } from './rest-request-method'; import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { BrowseService } from '../browse/browse.service'; import { CollectionDataService } from './collection-data.service'; -import { map, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { NoContent } from '../shared/NoContent.model'; -import { hasValue } from '../../shared/empty.util'; -import { Operation } from 'fast-json-patch'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { CoreState } from '../core-state.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { CreateDataImpl } from './base/create-data'; /** - * A custom implementation of the ItemDataService, but for collection item templates - * Makes sure to change the endpoint before sending out CRUD requests for the item template + * Data service for interacting with Item templates via their Collection */ -class DataServiceImpl extends ItemDataService { - protected collectionLinkPath = 'itemtemplate'; - protected linkPath = 'itemtemplates'; - - /** - * Endpoint dynamically changing depending on what request we're sending - */ - private endpoint$: Observable; - - /** - * Is the current endpoint based on a collection? - */ - private collectionEndpoint = false; +class CollectionItemTemplateDataService extends IdentifiableDataService { + private createData: CreateDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - super(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService); + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, undefined); + + // We only intend to use createOnEndpoint, so this inner data service feature doesn't need an endpoint at all + this.createData = new CreateDataImpl(undefined, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); } /** - * Get the endpoint based on a collection - * @param collectionID The ID of the collection to base the endpoint on + * Create an observable for the HREF of a specific object based on its identifier + * + * Overridden to ensure that {@link findById} works with Collection IDs and points to the template. + * @param collectionID the ID of a Collection */ - public getCollectionEndpoint(collectionID: string): Observable { + public getIDHrefObs(collectionID: string): Observable { return this.collectionService.getIDHrefObs(collectionID).pipe( - switchMap((href: string) => this.halService.getEndpoint(this.collectionLinkPath, href)) + switchMap((href: string) => this.halService.getEndpoint('itemtemplate', href)), ); } /** - * Set the endpoint to be based on a collection - * @param collectionID The ID of the collection to base the endpoint on - */ - private setCollectionEndpoint(collectionID: string) { - this.collectionEndpoint = true; - this.endpoint$ = this.getCollectionEndpoint(collectionID); - } - - /** - * Set the endpoint to the regular linkPath - */ - private setRegularEndpoint() { - this.collectionEndpoint = false; - this.endpoint$ = this.halService.getEndpoint(this.linkPath); - } - - /** - * Get the base endpoint for all requests - * Uses the current collectionID to assemble a request endpoint for the collection's item template - */ - protected getEndpoint(): Observable { - return this.endpoint$; - } - - /** - * If the current endpoint is based on a collection, simply return the collection's template endpoint, otherwise - * create a regular template endpoint - * @param resourceID - */ - getIDHrefObs(resourceID: string): Observable { - if (this.collectionEndpoint) { - return this.getEndpoint(); - } else { - return super.getIDHrefObs(resourceID); - } - } - - /** - * Set the collection ID and send a find by ID request - * @param collectionID - * @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 - */ - findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - this.setCollectionEndpoint(collectionID); - return super.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Set the collection ID and send a create request + * Create a new item template for a Collection by ID * @param item * @param collectionID */ - createTemplate(item: Item, collectionID: string): Observable> { - this.setCollectionEndpoint(collectionID); - return super.create(item); - } - - /** - * Set the collection ID and send a delete request - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - this.setRegularEndpoint(); - return super.delete(item.uuid).pipe( - getFirstCompletedRemoteData(), - map((response: RemoteData) => hasValue(response) && response.hasSucceeded) - ); + public createTemplate(item: Item, collectionID: string): Observable> { + return this.createData.createOnEndpoint(item, this.getIDHrefObs(collectionID)); } } @@ -149,43 +64,23 @@ class DataServiceImpl extends ItemDataService { * A service responsible for fetching/sending data from/to the REST API on a collection's itemtemplates endpoint */ @Injectable() -export class ItemTemplateDataService implements UpdateDataService { - /** - * The data service responsible for all CRUD actions on the item - */ - private dataService: DataServiceImpl; +export class ItemTemplateDataService extends BaseItemDataService { + private byCollection: CollectionItemTemplateDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService, collectionService); - } + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); - /** - * Commit current object changes to the server - */ - commitUpdates(method?: RestRequestMethod) { - this.dataService.commitUpdates(method); - } - - /** - * Add a new patch to the object cache - */ - update(object: Item): Observable> { - return this.dataService.update(object); - } - - patch(dso: Item, operations: Operation[]): Observable> { - return this.dataService.patch(dso, operations); + this.byCollection = new CollectionItemTemplateDataService(requestService, rdbService, objectCache, halService, notificationsService, collectionService); } /** @@ -199,7 +94,7 @@ export class ItemTemplateDataService implements UpdateDataService { * {@link HALLink}s should be automatically resolved */ findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByCollectionID(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.byCollection.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -207,17 +102,8 @@ export class ItemTemplateDataService implements UpdateDataService { * @param item * @param collectionID */ - create(item: Item, collectionID: string): Observable> { - return this.dataService.createTemplate(item, collectionID); - } - - /** - * Delete a template item by collection ID - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - return this.dataService.deleteByCollectionID(item, collectionID); + createByCollectionID(item: Item, collectionID: string): Observable> { + return this.byCollection.createTemplate(item, collectionID); } /** @@ -225,6 +111,6 @@ export class ItemTemplateDataService implements UpdateDataService { * @param collectionID The ID of the collection to base the endpoint on */ getCollectionEndpoint(collectionID: string): Observable { - return this.dataService.getCollectionEndpoint(collectionID); + return this.byCollection.getIDHrefObs(collectionID); } } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 54a174e365..09cbcb908a 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -10,6 +10,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RequestParam } from '../cache/models/request-param.model'; import { FindListOptions } from './find-list-options.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -33,16 +34,19 @@ describe('MetadataFieldDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - setStaleByHrefSubstring: {} + setStaleByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: createSuccessfulRemoteDataObject$(undefined) + buildSingle: createSuccessfulRemoteDataObject$(undefined), + buildList: createSuccessfulRemoteDataObject$(createPaginatedList([])), }); - metadataFieldService = new MetadataFieldDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataFieldService = new MetadataFieldDataService( + requestService, rdbService, undefined, halService, notificationsService, + ); } beforeEach(() => { diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 5a78213c84..e54ccb71c3 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,17 +1,11 @@ import { Injectable } from '@angular/core'; import { hasValue } from '../../shared/empty.util'; import { dataService } from '../cache/builders/build-decorators'; -import { DataService } from './data.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; @@ -19,29 +13,43 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { RequestParam } from '../cache/models/request-param.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { PutData, PutDataImpl } from './base/put-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { NoContent } from '../shared/NoContent.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint */ @Injectable() @dataService(METADATA_FIELD) -export class MetadataFieldDataService extends DataService { - protected linkPath = 'metadatafields'; +export class MetadataFieldDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, SearchData { + private createData: CreateData; + private searchData: SearchData; + private putData: PutData; + private deleteData: DeleteData; + protected searchBySchemaLinkPath = 'bySchema'; protected searchByFieldNameLinkPath = 'byFieldName'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadatafields', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -57,7 +65,7 @@ export class MetadataFieldDataService extends DataService { */ findBySchema(schema: MetadataSchema, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { const optionsWithSchema = Object.assign(new FindListOptions(), options, { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -85,8 +93,8 @@ export class MetadataFieldDataService extends DataService { new RequestParam('element', hasValue(element) ? element : ''), new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''), new RequestParam('query', hasValue(query) ? query : ''), - new RequestParam('exactName', hasValue(exactName) ? exactName : '') - ] + new RequestParam('exactName', hasValue(exactName) ? exactName : ''), + ], }); return this.searchBy(this.searchByFieldNameLinkPath, optionParams, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -112,4 +120,80 @@ export class MetadataFieldDataService extends DataService { } + + /** + * 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> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: MetadataField): Observable> { + return this.putData.put(object); + } + + /** + * 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: MetadataField, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * 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 + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * 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 + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts index 2e61955502..9c8fb9ca00 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -24,14 +24,20 @@ describe('MetadataSchemaDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = getMockRemoteDataBuildService(); - metadataSchemaService = new MetadataSchemaDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataSchemaService = new MetadataSchemaDataService( + requestService, + rdbService, + null, + halService, + notificationsService, + ); } beforeEach(() => { diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index f277f6cab6..97f806d237 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,6 +1,4 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -8,33 +6,45 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestService } from './request.service'; import { Observable } from 'rxjs'; import { hasValue } from '../../shared/empty.util'; import { tap } from 'rxjs/operators'; import { RemoteData } from './remote-data'; -import { CoreState } from '../core-state.model'; +import { PutData, PutDataImpl } from './base/put-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { NoContent } from '../shared/NoContent.model'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { FindListOptions } from './find-list-options.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { PaginatedList } from './paginated-list.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() @dataService(METADATA_SCHEMA) -export class MetadataSchemaDataService extends DataService { - protected linkPath = 'metadataschemas'; +export class MetadataSchemaDataService extends IdentifiableDataService implements FindAllData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private putData: PutData; + private deleteData: DeleteData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadataschemas', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -49,9 +59,9 @@ export class MetadataSchemaDataService extends DataService { const isUpdate = hasValue(schema.id); if (isUpdate) { - return this.put(schema); + return this.putData.put(schema); } else { - return this.create(schema); + return this.createData.create(schema); } } @@ -61,8 +71,50 @@ export class MetadataSchemaDataService extends DataService { */ clearRequests(): Observable { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } + /** + * 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 + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * 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 + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * 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. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/persistent-identifier-data.service.ts b/src/app/core/data/persistent-identifier-data.service.ts new file mode 100644 index 0000000000..661d8c044d --- /dev/null +++ b/src/app/core/data/persistent-identifier-data.service.ts @@ -0,0 +1,28 @@ +/** + * 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 { BaseDataService } from './base/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'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +export class PersistentIdentifierDataService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('pid', requestService, rdbService, objectCache, halService); + } +} diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 81b4cbd503..0168ded7c9 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,13 +1,8 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { Process } from '../../../process-page/processes/process.model'; import { dataService } from '../../cache/builders/build-decorators'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; @@ -17,24 +12,26 @@ import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; import { RemoteData } from '../remote-data'; import { BitstreamDataService } from '../bitstream-data.service'; -import { CoreState } from '../../core-state.model'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; +import { FindListOptions } from '../find-list-options.model'; @Injectable() @dataService(PROCESS) -export class ProcessDataService extends DataService { - protected linkPath = 'processes'; +export class ProcessDataService extends IdentifiableDataService implements FindAllData { + private findAllData: FindAllData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + ) { + super('processes', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -55,4 +52,22 @@ export class ProcessDataService extends DataService { const href$ = this.getFilesEndpoint(processId); return this.bitstreamDataService.findAllByHref(href$); } + + /** + * 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>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 75a66c822a..a05599c36c 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,18 +1,13 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { Script } from '../../../process-page/scripts/script.model'; import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; import { map, take } from 'rxjs/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; import { RemoteData } from '../remote-data'; -import { MultipartPostRequest} from '../request.models'; +import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; import { Observable } from 'rxjs'; import { dataService } from '../../cache/builders/build-decorators'; @@ -21,26 +16,29 @@ import { Process } from '../../../process-page/processes/process.model'; import { hasValue } from '../../../shared/empty.util'; import { getFirstCompletedRemoteData } from '../../shared/operators'; import { RestRequest } from '../rest-request.model'; -import { CoreState } from '../../core-state.model'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../paginated-list.model'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; @Injectable() @dataService(SCRIPT) -export class ScriptDataService extends DataService