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:
Yura Bondarenko
2022-08-24 12:54:27 +02:00
parent f6d2014bf4
commit 8f4b3b58fb
16 changed files with 2972 additions and 0 deletions

View 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');
});
});
});
});

View 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$;
}
}

View 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!)
});

View 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$;
}
}

View 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
);
});
});
});
});
});

View 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),
);
}
}

View 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&param2=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);
});
});
});
});

View 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)));
}
}

View 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);
});
});
});

View 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)));
}
}

View 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();
});
});
});

View 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)),
);
}
}

View 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!)
});

View 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);
}
}

View 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!)
});

View 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)),
);
}
}