mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
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
This commit is contained in:
627
src/app/core/data/base/base-data.service.spec.ts
Normal file
627
src/app/core/data/base/base-data.service.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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<jasmine.Func>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
373
src/app/core/data/base/base-data.service.ts
Normal file
373
src/app/core/data/base/base-data.service.ts
Normal file
@@ -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<Something> implements CreateData<Something>, SearchData<Something> {
|
||||||
|
* private createData: CreateData<Something>;
|
||||||
|
* private searchData: SearchDataData<Something>;
|
||||||
|
*
|
||||||
|
* create(...) {
|
||||||
|
* return this.createData.create(...);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* searchBy(...) {
|
||||||
|
* return this.searchData.searchBy(...);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class BaseDataService<T extends CacheableObject> {
|
||||||
|
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<string>}
|
||||||
|
*/
|
||||||
|
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
|
||||||
|
return this.getEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base endpoint for all requests
|
||||||
|
*/
|
||||||
|
protected getEndpoint(): Observable<string> {
|
||||||
|
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<string>}
|
||||||
|
* 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<T>[]): 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<string>}
|
||||||
|
* Return an observable that emits created HREF
|
||||||
|
*/
|
||||||
|
buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig<T>[]): 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<T>[]) {
|
||||||
|
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
|
||||||
|
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<T>[]): string[] {
|
||||||
|
let nestEmbed = embedString;
|
||||||
|
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
|
||||||
|
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<O>(shouldReRequest: boolean, requestFn: () => Observable<RemoteData<O>>) {
|
||||||
|
return (source: Observable<RemoteData<O>>): Observable<RemoteData<O>> => {
|
||||||
|
if (shouldReRequest === true) {
|
||||||
|
return source.pipe(
|
||||||
|
tap((remoteData: RemoteData<O>) => {
|
||||||
|
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<string>
|
||||||
|
* @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<string>, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
|
||||||
|
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<T>(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<T>) => 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<string>
|
||||||
|
* @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<string>, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
|
||||||
|
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<T>(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<PaginatedList<T>>) => 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<string>
|
||||||
|
* @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<string>, 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<boolean> {
|
||||||
|
const done$ = new AsyncSubject<boolean>();
|
||||||
|
|
||||||
|
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$;
|
||||||
|
}
|
||||||
|
}
|
112
src/app/core/data/base/create-data.spec.ts
Normal file
112
src/app/core/data/base/create-data.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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!)
|
||||||
|
});
|
107
src/app/core/data/base/create-data.ts
Normal file
107
src/app/core/data/base/create-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T extends CacheableObject> extends BaseDataService<T> implements CreateData<T> {
|
||||||
|
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<RemoteData<T>> {
|
||||||
|
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<string>): Observable<RemoteData<T>> {
|
||||||
|
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<T>(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<T>) => rd.isLoading, true)
|
||||||
|
).subscribe((rd: RemoteData<T>) => {
|
||||||
|
if (rd.hasFailed) {
|
||||||
|
this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result$;
|
||||||
|
}
|
||||||
|
}
|
208
src/app/core/data/base/delete-data.spec.ts
Normal file
208
src/app/core/data/base/delete-data.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
108
src/app/core/data/base/delete-data.ts
Normal file
108
src/app/core/data/base/delete-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<NoContent>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<NoContent>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteDataImpl<T extends CacheableObject> extends IdentifiableDataService<T> implements DeleteData<T> {
|
||||||
|
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<RemoteData<NoContent>> {
|
||||||
|
return this.getIDHrefObs(objectId).pipe(
|
||||||
|
switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
|
||||||
|
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<boolean>();
|
||||||
|
response$.pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
switchMap((rd: RemoteData<NoContent>) => {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
306
src/app/core/data/base/find-all-data.spec.ts
Normal file
306
src/app/core/data/base/find-all-data.spec.ts
Normal file
@@ -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<any>, 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<any> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
112
src/app/core/data/base/find-all-data.ts
Normal file
112
src/app/core/data/base/find-all-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<PaginatedList<T>>>}
|
||||||
|
* Return an observable that emits object list
|
||||||
|
*/
|
||||||
|
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the HREF with given options object
|
||||||
|
*
|
||||||
|
* @param options The [[FindListOptions]] object
|
||||||
|
* @param linkPath The link path for the object
|
||||||
|
* @return {Observable<string>}
|
||||||
|
* 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<T>[]): Observable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T extends CacheableObject> extends BaseDataService<T> implements FindAllData<T> {
|
||||||
|
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<RemoteData<PaginatedList<T>>>}
|
||||||
|
* Return an observable that emits object list
|
||||||
|
*/
|
||||||
|
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
|
||||||
|
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<string>}
|
||||||
|
* 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<T>[]): Observable<string> {
|
||||||
|
let endpoint$: Observable<string>;
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
145
src/app/core/data/base/identifiable-data.service.spec.ts
Normal file
145
src/app/core/data/base/identifiable-data.service.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
83
src/app/core/data/base/identifiable-data.service.ts
Normal file
83
src/app/core/data/base/identifiable-data.service.ts
Normal file
@@ -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<T extends CacheableObject> extends BaseDataService<T> {
|
||||||
|
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<T>[]): Observable<RemoteData<T>> {
|
||||||
|
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<T>[]): 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<T>[]): Observable<string> {
|
||||||
|
return this.getEndpoint().pipe(
|
||||||
|
map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow)));
|
||||||
|
}
|
||||||
|
}
|
180
src/app/core/data/base/patch-data.spec.ts
Normal file
180
src/app/core/data/base/patch-data.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
constructor(
|
||||||
|
protected requestService: RequestService,
|
||||||
|
protected rdbService: RemoteDataBuildService,
|
||||||
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected halService: HALEndpointService,
|
||||||
|
protected comparator: ChangeAnalyzer<Item>,
|
||||||
|
) {
|
||||||
|
super(undefined, requestService, rdbService, objectCache, halService, comparator, undefined, constructIdEndpointDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
|
||||||
|
return observableOf(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
143
src/app/core/data/base/patch-data.ts
Normal file
143
src/app/core/data/base/patch-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Operation[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T extends CacheableObject> extends IdentifiableDataService<T> implements PatchData<T> {
|
||||||
|
constructor(
|
||||||
|
protected linkPath: string,
|
||||||
|
protected requestService: RequestService,
|
||||||
|
protected rdbService: RemoteDataBuildService,
|
||||||
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected halService: HALEndpointService,
|
||||||
|
protected comparator: ChangeAnalyzer<T>,
|
||||||
|
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<RemoteData<T>> {
|
||||||
|
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<RemoteData<T>> {
|
||||||
|
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<Operation[]> {
|
||||||
|
const oldVersion$ = this.findByHref(object._links.self.href, true, false);
|
||||||
|
return oldVersion$.pipe(
|
||||||
|
getFirstSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
map((oldVersion: T) => this.comparator.diff(oldVersion, object)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
108
src/app/core/data/base/put-data.spec.ts
Normal file
108
src/app/core/data/base/put-data.spec.ts
Normal file
@@ -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<any> {
|
||||||
|
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<string> {
|
||||||
|
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!)
|
||||||
|
});
|
69
src/app/core/data/base/put-data.ts
Normal file
69
src/app/core/data/base/put-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* Send a PUT request for the specified object
|
||||||
|
*
|
||||||
|
* @param object The object to send a put request for.
|
||||||
|
*/
|
||||||
|
put(object: T): Observable<RemoteData<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T extends CacheableObject> extends BaseDataService<T> implements PutData<T> {
|
||||||
|
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<RemoteData<T>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
146
src/app/core/data/base/search-data.spec.ts
Normal file
146
src/app/core/data/base/search-data.spec.ts
Normal file
@@ -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<any>, 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<any> {
|
||||||
|
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<string> {
|
||||||
|
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!)
|
||||||
|
});
|
145
src/app/core/data/base/search-data.ts
Normal file
145
src/app/core/data/base/search-data.ts
Normal file
@@ -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<T extends CacheableObject> {
|
||||||
|
/**
|
||||||
|
* 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<RemoteData<PaginatedList<T>>}
|
||||||
|
* Return an observable that emits response from the server
|
||||||
|
*/
|
||||||
|
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>}
|
||||||
|
* 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<T>[]): Observable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T extends CacheableObject> extends BaseDataService<T> implements SearchData<T> {
|
||||||
|
/**
|
||||||
|
* @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<RemoteData<PaginatedList<T>>}
|
||||||
|
* Return an observable that emits response from the server
|
||||||
|
*/
|
||||||
|
searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
|
||||||
|
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<string>}
|
||||||
|
* 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<T>[]): Observable<string> {
|
||||||
|
let result$: Observable<string>;
|
||||||
|
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<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
|
filter((href: string) => isNotEmpty(href)),
|
||||||
|
map(href => this.constructSearchEndpoint(href, searchMethod)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user