Merge branch 'response-cache-refactoring' into w2p-54472_Create-community-and-collection-pages

Conflicts:
	src/app/core/auth/auth-response-parsing.service.ts
	src/app/core/auth/auth.service.ts
	src/app/core/auth/models/auth-status.model.ts
	src/app/core/auth/models/normalized-auth-status.model.ts
	src/app/core/auth/server-auth.service.ts
	src/app/core/cache/builders/remote-data-build.service.ts
	src/app/core/data/collection-data.service.ts
	src/app/core/data/comcol-data.service.spec.ts
	src/app/core/data/comcol-data.service.ts
	src/app/core/data/community-data.service.ts
	src/app/core/data/data.service.spec.ts
	src/app/core/data/data.service.ts
	src/app/core/data/dspace-object-data.service.ts
	src/app/core/data/item-data.service.ts
	src/app/core/data/request.models.ts
	src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
	src/app/core/shared/hal-endpoint.service.ts
	src/app/core/shared/operators.ts
	src/app/shared/testing/auth-request-service-stub.ts
	src/app/shared/testing/auth-service-stub.ts
This commit is contained in:
Kristof De Langhe
2018-10-29 10:26:44 +01:00
324 changed files with 9452 additions and 7948 deletions

View File

@@ -7,6 +7,8 @@ import { GlobalConfig } from '../../../config/global-config.interface';
import { GenericConstructor } from '../shared/generic-constructor';
import { PaginatedList } from './paginated-list';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { ResourceType } from '../shared/resource-type';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
@@ -25,7 +27,6 @@ export abstract class BaseResponseParsingService {
protected abstract toCache: boolean;
protected process<ObjectDomain, ObjectType>(data: any, requestHref: string): any {
if (isNotEmpty(data)) {
if (hasNoValue(data) || (typeof data !== 'object')) {
return data;
@@ -34,6 +35,7 @@ export abstract class BaseResponseParsingService {
} else if (Array.isArray(data)) {
return this.processArray(data, requestHref);
} else if (isObjectLevel(data)) {
data = this.fixBadEPersonRestResponse(data);
const object = this.deserialize(data);
if (isNotEmpty(data._embedded)) {
Object
@@ -53,6 +55,7 @@ export abstract class BaseResponseParsingService {
}
});
}
this.cache(object, requestHref);
return object;
}
@@ -122,7 +125,7 @@ export abstract class BaseResponseParsingService {
if (hasNoValue(co) || hasNoValue(co.self)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref);
this.objectCache.add(co, this.EnvConfig.cache.msToLive.default, requestHref);
}
processPageInfo(payload: any): PageInfo {
@@ -145,4 +148,23 @@ export abstract class BaseResponseParsingService {
}
return obj[keys[0]];
}
// TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed
// See https://github.com/DSpace/dspace-angular/issues/292
private fixBadEPersonRestResponse(obj: any): any {
if (obj.type === ResourceType.EPerson) {
const groups = obj.groups;
const normGroups = [];
if (isNotEmpty(groups)) {
groups.forEach((group) => {
const parts = ['eperson', 'groups', group.uuid];
const href = new RESTURLCombiner(this.EnvConfig, ...parts).toString();
normGroups.push(href);
}
)
}
return Object.assign({}, obj, { groups: normGroups });
}
return obj;
}
}

View File

@@ -1,5 +1,5 @@
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models';
import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service';
import { BrowseEntriesRequest } from './request.models';

View File

@@ -7,7 +7,7 @@ import {
ErrorResponse,
GenericSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
} from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { BrowseEntry } from '../shared/browse-entry.model';

View File

@@ -0,0 +1,168 @@
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service';
import { BrowseEntriesRequest, BrowseItemsRequest } from './request.models';
import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service';
describe('BrowseItemsResponseParsingService', () => {
let service: BrowseItemsResponseParsingService;
beforeEach(() => {
service = new BrowseItemsResponseParsingService(undefined, getMockObjectCacheService());
});
describe('parse', () => {
const request = new BrowseItemsRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/discover/browses/author/items');
const validResponse = {
payload: {
_embedded: {
items: [
{
id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India',
handle: '10986/17472',
metadata: [
{
key: 'dc.creator',
value: 'World Bank',
language: null
}
],
inArchive: true,
discoverable: true,
withdrawn: false,
lastModified: '2018-05-25T09:32:58.005+0000',
type: 'item',
_links: {
bitstreams: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams'
},
owningCollection: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection'
},
templateItemOf: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf'
},
self: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7'
}
}
},
{
id: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b',
uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b',
name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India',
handle: '10986/17475',
metadata: [
{
key: 'dc.creator',
value: 'World Bank',
language: null
}
],
inArchive: true,
discoverable: true,
withdrawn: false,
lastModified: '2018-05-25T09:33:42.526+0000',
type: 'item',
_links: {
bitstreams: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/bitstreams'
},
owningCollection: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/owningCollection'
},
templateItemOf: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/templateItemOf'
},
self: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b'
}
}
}
]
},
_links: {
first: {
href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=0&size=2'
},
self: {
href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items'
},
next: {
href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=1&size=2'
},
last: {
href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=7&size=2'
}
},
page: {
size: 2,
totalElements: 16,
totalPages: 8,
number: 0
}
},
statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponseNotAList = {
payload: {
id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7',
name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India',
handle: '10986/17472',
metadata: [
{
key: 'dc.creator',
value: 'World Bank',
language: null
}
],
inArchive: true,
discoverable: true,
withdrawn: false,
lastModified: '2018-05-25T09:32:58.005+0000',
type: 'item',
_links: {
bitstreams: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams'
},
owningCollection: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection'
},
templateItemOf: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf'
},
self: {
href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7'
}
}
},
statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponseStatusCode = {
payload: {}, statusCode: '500'
} as DSpaceRESTV2Response;
it('should return a GenericSuccessResponse if data contains a valid browse items response', () => {
const response = service.parse(request, validResponse);
expect(response.constructor).toBe(GenericSuccessResponse);
});
it('should return an ErrorResponse if data contains an invalid browse entries response', () => {
const response = service.parse(request, invalidResponseNotAList);
expect(response.constructor).toBe(ErrorResponse);
});
it('should return an ErrorResponse if data contains a statuscode other than 200', () => {
const response = service.parse(request, invalidResponseStatusCode);
expect(response.constructor).toBe(ErrorResponse);
});
});
});

View File

@@ -0,0 +1,58 @@
import { Inject, Injectable } from '@angular/core';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import {
ErrorResponse,
GenericSuccessResponse,
RestResponse
} from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model';
/**
* A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[])
*/
@Injectable()
export class BrowseItemsResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = {
getConstructor: () => DSpaceObject
};
protected toCache = false;
constructor(
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected objectCache: ObjectCacheService,
) { super();
}
/**
* Parses data from the browse endpoint to a list of DSpaceObjects
* @param {RestRequest} request
* @param {DSpaceRESTV2Response} data
* @returns {RestResponse}
*/
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded)
&& Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceRESTv2Serializer(DSpaceObject);
const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from browse endpoint'),
{ statusText: data.statusCode }
)
);
}
}
}

View File

@@ -1,6 +1,6 @@
import { BrowseResponseParsingService } from './browse-response-parsing.service';
import { BrowseEndpointRequest } from './request.models';
import { GenericSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { GenericSuccessResponse, ErrorResponse } from '../cache/response.models';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
@@ -10,134 +10,148 @@ describe('BrowseResponseParsingService', () => {
beforeEach(() => {
service = new BrowseResponseParsingService();
});
let validRequest;
let validResponse;
let invalidResponse1;
let invalidResponse2;
let invalidResponse3;
let definitions;
describe('parse', () => {
const validRequest = new BrowseEndpointRequest('client/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses');
beforeEach(() => {
validRequest = new BrowseEndpointRequest('client/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses');
const validResponse = {
payload: {
_embedded: {
browses: [{
metadataBrowse: false,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
validResponse = {
payload: {
_embedded: {
browses: [{
metadataBrowse: false,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.date.issued'],
_links: {
self: { href: 'https://rest.api/discover/browses/dateissued' },
items: { href: 'https://rest.api/discover/browses/dateissued/items' }
}
}, {
metadataBrowse: true,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.contributor.*', 'dc.creator'],
_links: {
self: { href: 'https://rest.api/discover/browses/author' },
entries: { href: 'https://rest.api/discover/browses/author/entries' },
items: { href: 'https://rest.api/discover/browses/author/items' }
}
}]
},
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response;
invalidResponse1 = {
payload: {
_embedded: {
browse: {
metadataBrowse: false,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.date.issued'],
_links: {
self: { href: 'https://rest.api/discover/browses/dateissued' },
items: { href: 'https://rest.api/discover/browses/dateissued/items' }
}
}
},
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response;
invalidResponse2 = {
payload: {
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response;
invalidResponse3 = {
payload: {
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '500'
} as DSpaceRESTV2Response;
definitions = [
Object.assign(new BrowseDefinition(), {
metadataBrowse: false,
sortOptions: [
{
name: 'title',
metadata: 'dc.title'
},
{
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.date.issued'],
_links: {
self: { href: 'https://rest.api/discover/browses/dateissued' },
items: { href: 'https://rest.api/discover/browses/dateissued/items' }
},
{
name: 'dateaccessioned',
metadata: 'dc.date.accessioned'
}
}, {
metadataBrowse: true,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
],
defaultSortOrder: 'ASC',
type: 'browse',
metadataKeys: [
'dc.date.issued'
],
_links: {
self: 'https://rest.api/discover/browses/dateissued',
items: 'https://rest.api/discover/browses/dateissued/items'
}
}),
Object.assign(new BrowseDefinition(), {
metadataBrowse: true,
sortOptions: [
{
name: 'title',
metadata: 'dc.title'
},
{
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.contributor.*', 'dc.creator'],
_links: {
self: { href: 'https://rest.api/discover/browses/author' },
entries: { href: 'https://rest.api/discover/browses/author/entries' },
items: { href: 'https://rest.api/discover/browses/author/items' }
}
}]
},
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponse1 = {
payload: {
_embedded: {
browse: {
metadataBrowse: false,
sortOptions: [{ name: 'title', metadata: 'dc.title' }, {
name: 'dateissued',
metadata: 'dc.date.issued'
}, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }],
order: 'ASC',
type: 'browse',
metadata: ['dc.date.issued'],
_links: {
self: { href: 'https://rest.api/discover/browses/dateissued' },
items: { href: 'https://rest.api/discover/browses/dateissued/items' }
},
{
name: 'dateaccessioned',
metadata: 'dc.date.accessioned'
}
],
defaultSortOrder: 'ASC',
type: 'browse',
metadataKeys: [
'dc.contributor.*',
'dc.creator'
],
_links: {
self: 'https://rest.api/discover/browses/author',
entries: 'https://rest.api/discover/browses/author/entries',
items: 'https://rest.api/discover/browses/author/items'
}
},
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponse2 = {
payload: {
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '200'
} as DSpaceRESTV2Response ;
const invalidResponse3 = {
payload: {
_links: { self: { href: 'https://rest.api/discover/browses' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '500'
} as DSpaceRESTV2Response;
const definitions = [
Object.assign(new BrowseDefinition(), {
metadataBrowse: false,
sortOptions: [
{
name: 'title',
metadata: 'dc.title'
},
{
name: 'dateissued',
metadata: 'dc.date.issued'
},
{
name: 'dateaccessioned',
metadata: 'dc.date.accessioned'
}
],
defaultSortOrder: 'ASC',
type: 'browse',
metadataKeys: [
'dc.date.issued'
],
_links: { }
}),
Object.assign(new BrowseDefinition(), {
metadataBrowse: true,
sortOptions: [
{
name: 'title',
metadata: 'dc.title'
},
{
name: 'dateissued',
metadata: 'dc.date.issued'
},
{
name: 'dateaccessioned',
metadata: 'dc.date.accessioned'
}
],
defaultSortOrder: 'ASC',
type: 'browse',
metadataKeys: [
'dc.contributor.*',
'dc.creator'
],
_links: { }
})
];
})
];
});
it('should return a GenericSuccessResponse if data contains a valid browse endpoint response', () => {
const response = service.parse(validRequest, validResponse);
expect(response.constructor).toBe(GenericSuccessResponse);

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models';
import { isNotEmpty } from '../../shared/empty.util';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { BrowseDefinition } from '../shared/browse-definition.model';

View File

@@ -1,10 +1,8 @@
import { Inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedCollection } from '../cache/models/normalized-collection.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { Collection } from '../shared/collection.model';
import { ComColDataService } from './comcol-data.service';
@@ -21,7 +19,6 @@ export class CollectionDataService extends ComColDataService<NormalizedCollectio
protected linkPath = 'collections';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,

View File

@@ -1,19 +1,19 @@
import { Store } from '@ngrx/store';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { Observable, TestScheduler } from 'rxjs/Rx';
import { TestScheduler } from 'rxjs/testing';
import { GlobalConfig } from '../../../config';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { ComColDataService } from './comcol-data.service';
import { CommunityDataService } from './community-data.service';
import { FindByIDRequest } from './request.models';
import { FindAllOptions, FindByIDRequest } from './request.models';
import { RequestService } from './request.service';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestEntry } from './request.reducer';
import { of as observableOf } from 'rxjs';
import { Community } from '../shared/community.model';
import { AuthService } from '../auth/auth.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -28,7 +28,6 @@ class NormalizedTestObject extends NormalizedObject {
class TestService extends ComColDataService<NormalizedTestObject, any> {
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
@@ -44,12 +43,12 @@ class TestService extends ComColDataService<NormalizedTestObject, any> {
super();
}
}
/* tslint:enable:max-classes-per-file */
describe('ComColDataService', () => {
let scheduler: TestScheduler;
let service: TestService;
let responseCache: ResponseCacheService;
let requestService: RequestService;
let cds: CommunityDataService;
let objectCache: ObjectCacheService;
@@ -63,6 +62,15 @@ describe('ComColDataService', () => {
const http = {} as HttpClient;
const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
const options = Object.assign(new FindAllOptions(), {
scopeID: scopeID
});
const getRequestEntry$ = (successful: boolean) => {
return observableOf({
response: { isSuccessful: successful } as any
} as RequestEntry)
};
const communitiesEndpoint = 'https://rest.api/core/communities';
const communityEndpoint = `${communitiesEndpoint}/${scopeID}`;
const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`;
@@ -80,14 +88,6 @@ describe('ComColDataService', () => {
});
}
function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService {
return jasmine.createSpyObj('responseCache', {
get: cold('c-', {
c: { response: { isSuccessful } }
})
});
}
function initMockObjectCacheService(): ObjectCacheService {
return jasmine.createSpyObj('objectCache', {
getByUUID: cold('d-', {
@@ -110,7 +110,6 @@ describe('ComColDataService', () => {
function initTestService(): TestService {
return new TestService(
responseCache,
requestService,
rdbService,
store,
@@ -129,52 +128,62 @@ describe('ComColDataService', () => {
cds = initMockCommunityDataService();
requestService = getMockRequestService();
objectCache = initMockObjectCacheService();
responseCache = initMockResponseCacheService(true);
halService = mockHalService;
authService = initMockAuthService();
service = initTestService();
});
describe('getScopedEndpoint', () => {
describe('getBrowseEndpoint', () => {
beforeEach(() => {
scheduler = getTestScheduler();
});
it('should configure a new FindByIDRequest for the scope Community', () => {
cds = initMockCommunityDataService();
requestService = getMockRequestService(getRequestEntry$(true));
objectCache = initMockObjectCacheService();
service = initTestService();
const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID);
scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe());
scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
describe('if the scope Community can be found', () => {
beforeEach(() => {
cds = initMockCommunityDataService();
requestService = getMockRequestService(getRequestEntry$(true));
objectCache = initMockObjectCacheService();
service = initTestService();
});
it('should fetch the scope Community from the cache', () => {
scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe());
scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe());
scheduler.flush();
expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID);
});
it('should return the endpoint to fetch resources within the given scope', () => {
const result = service.getScopedEndpoint(scopeID);
const expected = cold('--e-', { e: scopedEndpoint });
const result = service.getBrowseEndpoint(options);
const expected = '--e-';
expect(result).toBeObservable(expected);
scheduler.expectObservable(result).toBe(expected, { e: scopedEndpoint });
});
});
describe('if the scope Community can\'t be found', () => {
beforeEach(() => {
cds = initMockCommunityDataService();
requestService = getMockRequestService();
requestService = getMockRequestService(getRequestEntry$(false));
objectCache = initMockObjectCacheService();
responseCache = initMockResponseCacheService(false);
service = initTestService();
});
it('should throw an error', () => {
const result = service.getScopedEndpoint(scopeID);
const result = service.getBrowseEndpoint(options);
const expected = cold('--#-', undefined, new Error(`The Community with scope ${scopeID} couldn't be retrieved`));
expect(result).toBeObservable(expected);

View File

@@ -1,27 +1,27 @@
import { Observable } from 'rxjs/Observable';
import { isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import {
distinctUntilChanged,
filter,
first,
map,
mergeMap,
share,
take,
tap
} from 'rxjs/operators';
import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { CommunityDataService } from './community-data.service';
import { DataService } from './data.service';
import { FindByIDRequest, PostRequest, PutRequest, RequestError, RestRequest } from './request.models';
import { FindAllOptions, FindByIDRequest } from './request.models';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Community } from '../shared/community.model';
import { Collection } from '../shared/collection.model';
import { catchError, distinctUntilChanged, map } from 'rxjs/operators';
import { configureRequest, getResponseFromSelflink } from '../shared/operators';
import { AuthService } from '../auth/auth.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HttpHeaders } from '@angular/common/http';
import { RemoteData } from './remote-data';
import { RequestEntry } from './request.reducer';
import { getResponseFromEntry } from '../shared/operators';
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain> extends DataService<TNormalized, TDomain> {
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain> extends DataService<TNormalized, TDomain> {
protected abstract cds: CommunityDataService;
protected abstract objectCache: ObjectCacheService;
protected abstract halService: HALEndpointService;
@@ -36,34 +36,52 @@ export abstract class ComColDataService<TNormalized extends NormalizedObject, TD
* @return { Observable<string> }
* an Observable<string> containing the scoped URL
*/
public getScopedEndpoint(scopeID: string): Observable<string> {
if (isEmpty(scopeID)) {
return this.halService.getEndpoint(this.linkPath);
public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> {
if (isEmpty(options.scopeID)) {
return this.halService.getEndpoint(linkPath);
} else {
const scopeCommunityHrefObs = this.cds.getEndpoint()
.flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID))
.filter((href: string) => isNotEmpty(href))
.take(1)
.do((href: string) => {
const request = new FindByIDRequest(this.requestService.generateRequestId(), href, scopeID);
const scopeCommunityHrefObs = this.cds.getEndpoint().pipe(
mergeMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, options.scopeID)),
filter((href: string) => isNotEmpty(href)),
take(1),
tap((href: string) => {
const request = new FindByIDRequest(this.requestService.generateRequestId(), href, options.scopeID);
this.requestService.configure(request);
});
}));
const [successResponse, errorResponse] = scopeCommunityHrefObs
.flatMap((href: string) => this.responseCache.get(href))
.map((entry: ResponseCacheEntry) => entry.response)
.share()
.partition((response: RestResponse) => response.isSuccessful);
// return scopeCommunityHrefObs.pipe(
// mergeMap((href: string) => this.responseCache.get(href)),
// map((entry: ResponseCacheEntry) => entry.response),
// mergeMap((response) => {
// if (response.isSuccessful) {
// const community$: Observable<NormalizedCommunity> = this.objectCache.getByUUID(scopeID);
// return community$.pipe(
// map((community) => community._links[linkPath]),
// filter((href) => isNotEmpty(href)),
// distinctUntilChanged()
// );
// } else if (!response.isSuccessful) {
// return observableThrowError(new Error(`The Community with scope ${scopeID} couldn't be retrieved`))
// }
// }),
// distinctUntilChanged()
// );
const responses = scopeCommunityHrefObs.pipe(
mergeMap((href: string) => this.requestService.getByHref(href)),
getResponseFromEntry()
);
const errorResponses = responses.pipe(
filter((response) => !response.isSuccessful),
mergeMap(() => observableThrowError(new Error(`The Community with scope ${options.scopeID} couldn't be retrieved`)))
);
const successResponses = responses.pipe(
filter((response) => response.isSuccessful),
mergeMap(() => this.objectCache.getByUUID(options.scopeID)),
map((nc: NormalizedCommunity) => nc._links[linkPath]),
filter((href) => isNotEmpty(href))
);
return Observable.merge(
errorResponse.flatMap((response: ErrorResponse) =>
Observable.throw(new Error(`The Community with scope ${scopeID} couldn't be retrieved`))),
successResponse
.flatMap((response: DSOSuccessResponse) => this.objectCache.getByUUID(scopeID))
.map((nc: NormalizedCommunity) => nc._links[this.linkPath])
.filter((href) => isNotEmpty(href))
).distinctUntilChanged();
return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share());
}
}
}

View File

@@ -1,10 +1,10 @@
import { filter, mergeMap, take } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { Community } from '../shared/community.model';
import { ComColDataService } from './comcol-data.service';
@@ -14,7 +14,7 @@ import { AuthService } from '../auth/auth.service';
import { FindAllOptions, FindAllRequest } from './request.models';
import { RemoteData } from './remote-data';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { PaginatedList } from './paginated-list';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
@@ -26,7 +26,6 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
protected cds = this;
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
@@ -44,12 +43,10 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
}
findTop(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Community>>> {
const hrefObs = this.halService.getEndpoint(this.topLinkPath).filter((href: string) => isNotEmpty(href))
.flatMap((endpoint: string) => this.getFindAllHref(endpoint, options));
hrefObs
.filter((href: string) => hasValue(href))
.take(1)
const hrefObs = this.getFindAllHref(options, this.topLinkPath);
hrefObs.pipe(
filter((href: string) => hasValue(href)),
take(1))
.subscribe((href: string) => {
const request = new FindAllRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);

View File

@@ -1,4 +1,4 @@
import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models';
import { ConfigResponseParsingService } from './config-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';

View File

@@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models';
import { isNotEmpty } from '../../shared/empty.util';
import { ConfigObjectFactory } from '../shared/config/config-object-factory';

View File

@@ -1,223 +1,181 @@
import { DataService } from './data.service';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers';
import { Store } from '@ngrx/store';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { FindAllOptions } from './request.models';
import { SortOptions, SortDirection } from '../cache/models/sort-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { AuthService } from '../auth/auth.service';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { of as observableOf } from 'rxjs';
import { ObjectCacheService } from '../cache/object-cache.service';
import { Operation } from '../../../../node_modules/fast-json-patch';
import { DSpaceObject } from '../shared/dspace-object.model';
import { RemoteData } from './remote-data';
import { RequestEntry } from './request.reducer';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { EmptyError } from 'rxjs/util/EmptyError';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { hasValue } from '../../shared/empty.util';
import { map } from 'rxjs/operators';
import { RemoteDataError } from './remote-data-error';
const LINK_NAME = 'test';
const endpoint = 'https://rest.api/core';
// tslint:disable:max-classes-per-file
class NormalizedTestObject extends NormalizedObject {
}
class TestService extends DataService<NormalizedTestObject, any> {
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected linkPath: string,
protected halService: HALEndpointService,
protected authService: AuthService,
protected notificationsService: NotificationsService,
protected http: HttpClient
) {
super();
}
public getScopedEndpoint(scope: string): Observable<string> {
throw new Error('getScopedEndpoint is abstract in DataService');
}
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected linkPath: string,
protected halService: HALEndpointService,
protected objectCache: ObjectCacheService
) {
super();
}
public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('DataService', () => {
let service: TestService;
let options: FindAllOptions;
let responseCache = getMockResponseCacheService();
let rdbService = {} as RemoteDataBuildService;
const authService = {} as AuthService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const store = {} as Store<CoreState>;
const endpoint = 'https://rest.api/core';
const halService = Object.assign({
getEndpoint: (linkpath) => Observable.of(endpoint)
});
const requestService = Object.assign(getMockRequestService(), {
getByUUID: () => Observable.of(new RequestEntry()),
configure: (request) => request
});
let service: TestService;
let options: FindAllOptions;
const requestService = {} as RequestService;
const halService = {} as HALEndpointService;
const rdbService = {} as RemoteDataBuildService;
const objectCache = {
addPatch: () => {
/* empty */
},
getBySelfLink: () => {
/* empty */
}
} as any;
const store = {} as Store<CoreState>;
const dso = new DSpaceObject();
const successfulRd$ = Observable.of(new RemoteData(false, false, true, undefined, dso));
const successfulResponseCacheEntry = Object.assign({
response: {
isSuccessful: true,
payload: dso,
toCache: true,
statusCode: '200',
resourceSelfLinks: [
endpoint
]
} as DSOSuccessResponse
}) as ResponseCacheEntry;
function initTestService(): TestService {
return new TestService(
requestService,
rdbService,
store,
endpoint,
halService,
objectCache
);
}
function initSuccessfulRemoteDataBuildService(): RemoteDataBuildService {
return {
buildSingle: (selfLinks$: Observable<any>) => {
selfLinks$.subscribe();
return successfulRd$;
service = initTestService();
describe('getFindAllHref', () => {
it('should return an observable with the endpoint', () => {
options = {};
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(endpoint);
}
} as RemoteDataBuildService;
}
function initSuccessfulResponseCacheService(): ResponseCacheService {
return getMockResponseCacheService(Observable.of(new ResponseCacheEntry()), Observable.of(successfulResponseCacheEntry));
}
function initTestService(): TestService {
return new TestService(
responseCache,
requestService,
rdbService,
store,
LINK_NAME,
halService,
authService,
notificationsService,
http
);
}
service = initTestService();
describe('getFindAllHref', () => {
it('should return an observable with the endpoint', () => {
options = {};
(service as any).getFindAllHref(endpoint).subscribe((value) => {
expect(value).toBe(endpoint);
}
);
});
// getScopedEndpoint is not implemented in abstract DataService
it('should throw error if scopeID provided in options', () => {
options = { scopeID: 'somevalue' };
expect(() => { (service as any).getFindAllHref(endpoint, options) })
.toThrowError('getScopedEndpoint is abstract in DataService');
});
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(endpoint, 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(endpoint, 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(endpoint, 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(endpoint, 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(endpoint, options).subscribe((value) => {
expect(value).toBe(expected);
});
})
);
});
describe('create', () => {
it('should include page in href if currentPage provided in options', () => {
options = { currentPage: 2 };
const expected = `${endpoint}?page=${options.currentPage - 1}`;
describe('when the request was successful', () => {
beforeEach(() => {
responseCache = initSuccessfulResponseCacheService();
rdbService = initSuccessfulRemoteDataBuildService();
service = initTestService();
});
it('should return a RemoteData of a DSpaceObject', () => {
service.create(dso, undefined).subscribe((rd: RemoteData<DSpaceObject>) => {
expect(rd.payload).toBe(dso);
});
});
it('should get the response from cache with the correct url when parent is empty', () => {
const expectedUrl = endpoint;
service.create(dso, undefined).subscribe((value) => {
expect(responseCache.get).toHaveBeenCalledWith(expectedUrl);
});
});
it('should get the response from cache with the correct url when parent is not empty', () => {
const parent = 'fake-parent-uuid';
const expectedUrl = `${endpoint}?parent=${parent}`;
service.create(dso, parent).subscribe((value) => {
expect(responseCache.get).toHaveBeenCalledWith(expectedUrl);
});
});
(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);
});
})
});
describe('patch', () => {
let operations;
let selfLink;
beforeEach(() => {
operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
spyOn(objectCache, 'addPatch');
});
it('should call addPatch on the object cache with the right parameters', () => {
service.patch(selfLink, operations);
expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
});
});
describe('update', () => {
let operations;
let selfLink;
let dso;
let dso2;
const name1 = 'random string';
const name2 = 'another random string';
beforeEach(() => {
operations = [{ op: 'replace', path: '/name', value: name2 } as Operation];
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
dso = new DSpaceObject();
dso.self = selfLink;
dso.name = name1;
dso2 = new DSpaceObject();
dso2.self = selfLink;
dso2.name = name2;
spyOn(objectCache, 'getBySelfLink').and.returnValue(dso);
spyOn(objectCache, 'addPatch');
});
it('should call addPatch on the object cache with the right parameters when there are differences', () => {
service.update(dso2);
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);
expect(objectCache.addPatch).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,8 +1,8 @@
import { distinctUntilChanged, filter, first, map, take } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { URLCombiner } from '../url-combiner/url-combiner';
@@ -13,78 +13,68 @@ import {
FindAllOptions,
FindAllRequest,
FindByIDRequest,
GetRequest,
RestRequest
GetRequest
} from './request.models';
import { RequestService } from './request.service';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { distinctUntilChanged, first, map, take, tap } from 'rxjs/operators';
import { compare, Operation } from 'fast-json-patch';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { AuthService } from '../auth/auth.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import {
configureRequest,
filterSuccessfulResponses,
getResponseFromSelflink
getResponseFromEntry
} from '../shared/operators';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { HttpClient } from '@angular/common/http';
import { DSOSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { AuthService } from '../auth/auth.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
export abstract class DataService<TNormalized extends NormalizedObject, TDomain> {
protected abstract responseCache: ResponseCacheService;
protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService;
protected abstract store: Store<CoreState>;
protected abstract linkPath: string;
protected abstract halService: HALEndpointService;
protected abstract objectCache: ObjectCacheService;
protected abstract authService: AuthService;
protected abstract notificationsService: NotificationsService;
protected abstract http: HttpClient;
public abstract getScopedEndpoint(scope: string): Observable<string>
public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable<string>
protected getFindAllHref(endpoint, options: FindAllOptions = {}): Observable<string> {
protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable<string> {
let result: Observable<string>;
const args = [];
if (hasValue(options.scopeID)) {
result = this.getScopedEndpoint(options.scopeID).distinctUntilChanged();
} else {
result = Observable.of(endpoint);
}
result = this.getBrowseEndpoint(options, linkPath);
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.push(`page=${options.currentPage - 1}`);
}
if (hasValue(options.elementsPerPage)) {
args.push(`size=${options.elementsPerPage}`);
}
if (hasValue(options.sort)) {
args.push(`sort=${options.sort.field},${options.sort.direction}`);
}
if (hasValue(options.startsWith)) {
args.push(`startsWith=${options.startsWith}`);
}
if (isNotEmpty(args)) {
return result.map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString());
return result.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString()));
} else {
return result;
}
}
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
const hrefObs = this.halService.getEndpoint(this.linkPath).filter((href: string) => isNotEmpty(href))
.flatMap((endpoint: string) => this.getFindAllHref(endpoint, options));
const hrefObs = this.getFindAllHref(options);
hrefObs
.filter((href: string) => hasValue(href))
.take(1)
hrefObs.pipe(
filter((href: string) => hasValue(href)),
take(1))
.subscribe((href: string) => {
const request = new FindAllRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);
@@ -98,11 +88,11 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
}
findById(id: string): Observable<RemoteData<TDomain>> {
const hrefObs = this.halService.getEndpoint(this.linkPath)
.map((endpoint: string) => this.getFindByIDHref(endpoint, id));
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getFindByIDHref(endpoint, id)));
hrefObs
.first((href: string) => hasValue(href))
hrefObs.pipe(
first((href: string) => hasValue(href)))
.subscribe((href: string) => {
const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id);
this.requestService.configure(request);
@@ -116,6 +106,28 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
return this.rdbService.buildSingle<TNormalized, TDomain>(href);
}
/**
* Add a new patch to the object cache to a specified object
* @param {string} href The selflink of the object that will be patched
* @param {Operation[]} operations The patch operations to be performed
*/
patch(href: string, operations: Operation[]) {
this.objectCache.addPatch(href, operations);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
update(object: DSpaceObject) {
const oldVersion = this.objectCache.getBySelfLink(object.self);
const operations = compare(oldVersion, object);
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object.self, operations);
}
}
create(dso: TDomain, parentUUID: string): Observable<RemoteData<TDomain>> {
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe(
@@ -131,22 +143,20 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
);
const selfLink$ = request$.pipe(
map((request: RestRequest) => request.href),
getResponseFromSelflink(this.responseCache),
map((response: ResponseCacheEntry) => {
if (!response.response.isSuccessful && response.response instanceof ErrorResponse) {
const errorResponse: ErrorResponse = response.response;
this.notificationsService.error('Server Error:', errorResponse.errorMessage, new NotificationOptions(-1));
getResponseFromEntry(),
map((response: RestResponse) => {
if (!response.isSuccessful && response instanceof ErrorResponse) {
this.notificationsService.error('Server Error:', response.errorMessage, new NotificationOptions(-1));
} else {
return response;
}
return response;
}),
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: DSOSuccessResponse) => {
return response.resourceSelfLinks[0];
}),
distinctUntilChanged()
);
) as Observable<string>;
return this.rdbService.buildSingle(selfLink$) as Observable<RemoteData<TDomain>>;
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { RestResponse } from '../cache/response-cache.models';
import { RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';

View File

@@ -7,7 +7,7 @@ import { NormalizedObject } from '../cache/models/normalized-object.model';
import { ResourceType } from '../shared/resource-type';
import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestResponse, DSOSuccessResponse } from '../cache/response-cache.models';
import { RestResponse, DSOSuccessResponse } from '../cache/response.models';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
@@ -23,12 +23,14 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem
constructor(
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected objectCache: ObjectCacheService,
) { super();
) {
super();
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const processRequestDTO = this.process<NormalizedObject,ResourceType>(data.payload, request.href);
const processRequestDTO = this.process<NormalizedObject, ResourceType>(data.payload, request.href);
let objectList = processRequestDTO;
if (hasNoValue(processRequestDTO)) {
return new DSOSuccessResponse([], data.statusCode, undefined)
}

View File

@@ -1,11 +1,12 @@
import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from '../../../../node_modules/rxjs';
import { TestScheduler } from 'rxjs/testing';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FindByIDRequest } from './request.models';
import { RequestService } from './request.service';
import { DSpaceObjectDataService } from './dspace-object-data.service';
import { ObjectCacheService } from '../cache/object-cache.service';
describe('DSpaceObjectDataService', () => {
let scheduler: TestScheduler;
@@ -13,6 +14,7 @@ describe('DSpaceObjectDataService', () => {
let halService: HALEndpointService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
const testObject = {
uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746'
} as DSpaceObject;
@@ -37,11 +39,13 @@ describe('DSpaceObjectDataService', () => {
}
})
});
objectCache = {} as ObjectCacheService;
service = new DSpaceObjectDataService(
requestService,
rdbService,
halService
halService,
objectCache
)
});

View File

@@ -1,15 +1,16 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service';
import { FindAllOptions } from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { AuthService } from '../auth/auth.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
@@ -19,10 +20,10 @@ class DataServiceImpl extends DataService<NormalizedDSpaceObject, DSpaceObject>
protected linkPath = 'dso';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected authService: AuthService,
protected notificationsService: NotificationsService,
@@ -30,8 +31,8 @@ class DataServiceImpl extends DataService<NormalizedDSpaceObject, DSpaceObject>
super();
}
getScopedEndpoint(scope: string): Observable<string> {
return undefined;
getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return this.halService.getEndpoint(linkPath);
}
getFindByIDHref(endpoint, resourceID): string {
@@ -47,11 +48,12 @@ export class DSpaceObjectDataService {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected authService: AuthService,
protected notificationsService: NotificationsService,
protected http: HttpClient) {
this.dataService = new DataServiceImpl(null, requestService, rdbService, null, halService, authService, notificationsService, http);
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, authService, notificationsService, http);
}
findById(uuid: string): Observable<RemoteData<DSpaceObject>> {

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@angular/core';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response-cache.models';
import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core';
import {
FacetConfigSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
} from '../cache/response.models';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';

View File

@@ -4,7 +4,7 @@ import {
FacetValueMapSuccessResponse,
FacetValueSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
} from '../cache/response.models';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';

View File

@@ -4,7 +4,7 @@ import {
FacetValueMapSuccessResponse,
FacetValueSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
} from '../cache/response.models';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';

View File

@@ -1,25 +1,34 @@
import { Store } from '@ngrx/store';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/Rx';
import { TestScheduler } from 'rxjs/testing';
import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { ItemDataService } from './item-data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { FindAllOptions } from './request.models';
describe('ItemDataService', () => {
let scheduler: TestScheduler;
let service: ItemDataService;
let bs: BrowseService;
const requestService = {} as RequestService;
const responseCache = {} as ResponseCacheService;
const rdbService = {} as RemoteDataBuildService;
const objectCache = {} as ObjectCacheService;
const store = {} as Store<CoreState>;
const halEndpointService = {} as HALEndpointService;
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
const options = Object.assign(new FindAllOptions(), {
scopeID: scopeID,
sort: {
field: '',
direction: undefined
}
});
const browsesEndpoint = 'https://rest.api/discover/browses';
const itemBrowseEndpoint = `${browsesEndpoint}/author/items`;
const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`;
@@ -37,25 +46,25 @@ describe('ItemDataService', () => {
function initTestService() {
return new ItemDataService(
responseCache,
requestService,
rdbService,
store,
bs,
halEndpointService
halEndpointService,
objectCache
);
}
describe('getScopedEndpoint', () => {
describe('getBrowseEndpoint', () => {
beforeEach(() => {
scheduler = getTestScheduler();
});
it('should return the endpoint to fetch Items within the given scope', () => {
it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => {
bs = initMockBrowseService(true);
service = initTestService();
const result = service.getScopedEndpoint(scopeID);
const result = service.getBrowseEndpoint(options);
const expected = cold('--b-', { b: scopedEndpoint });
expect(result).toBeObservable(expected);
@@ -67,7 +76,7 @@ describe('ItemDataService', () => {
service = initTestService();
});
it('should throw an error', () => {
const result = service.getScopedEndpoint(scopeID);
const result = service.getBrowseEndpoint(options);
const expected = cold('--#-', undefined, browseError);
expect(result).toBeObservable(expected);

View File

@@ -1,13 +1,12 @@
import { Inject, Injectable } from '@angular/core';
import {distinctUntilChanged, map, filter} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { Observable } from 'rxjs';
import { isNotEmpty } from '../../shared/empty.util';
import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedItem } from '../cache/models/normalized-item.model';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { Item } from '../shared/item.model';
import { URLCombiner } from '../url-combiner/url-combiner';
@@ -15,6 +14,8 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FindAllOptions } from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { AuthService } from '../auth/auth.service';
import { HttpClient } from '@angular/common/http';
@@ -24,11 +25,11 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
protected linkPath = 'items';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
private bs: BrowseService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected authService: AuthService,
protected notificationsService: NotificationsService,
@@ -36,15 +37,21 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
super();
}
public getScopedEndpoint(scopeID: string): Observable<string> {
if (isEmpty(scopeID)) {
return this.halService.getEndpoint(this.linkPath);
} else {
return this.bs.getBrowseURLFor('dc.date.issued', this.linkPath)
.filter((href: string) => isNotEmpty(href))
.map((href: string) => new URLCombiner(href, `?scope=${scopeID}`).toString())
.distinctUntilChanged();
/**
* Get the endpoint for browsing items
* (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued')
* @param {FindAllOptions} options
* @returns {Observable<string>}
*/
public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable<string> {
let field = 'dc.date.issued';
if (options.sort && options.sort.field) {
field = options.sort.field;
}
return this.bs.getBrowseURLFor(field, linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()),
distinctUntilChanged(),);
}
}

View File

@@ -4,7 +4,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { Injectable } from '@angular/core';
import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models';
@Injectable()
export class MetadataschemaParsingService implements ResponseParsingService {

View File

@@ -1,6 +1,6 @@
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { RestResponse } from '../cache/response-cache.models';
import { RestResponse } from '../cache/response.models';
export interface ResponseParsingService {
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse;

View File

@@ -1,4 +1,4 @@
import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response.models';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';

View File

@@ -1,7 +1,7 @@
import {
RegistryMetadatafieldsSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
} from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';

View File

@@ -1,4 +1,4 @@
import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';

View File

@@ -1,6 +1,7 @@
import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { RestRequest } from './request.models';
import { RestResponse } from '../cache/response.models';
/**
* The list of RequestAction type definitions
@@ -8,7 +9,8 @@ import { RestRequest } from './request.models';
export const RequestActionTypes = {
CONFIGURE: type('dspace/core/data/request/CONFIGURE'),
EXECUTE: type('dspace/core/data/request/EXECUTE'),
COMPLETE: type('dspace/core/data/request/COMPLETE')
COMPLETE: type('dspace/core/data/request/COMPLETE'),
RESET_TIMESTAMPS: type('dspace/core/data/request/RESET_TIMESTAMPS')
};
/* tslint:disable:max-classes-per-file */
@@ -43,7 +45,10 @@ export class RequestExecuteAction implements Action {
*/
export class RequestCompleteAction implements Action {
type = RequestActionTypes.COMPLETE;
payload: string;
payload: {
uuid: string,
response: RestResponse
};
/**
* Create a new RequestCompleteAction
@@ -51,10 +56,32 @@ export class RequestCompleteAction implements Action {
* @param uuid
* the request's uuid
*/
constructor(uuid: string) {
this.payload = uuid;
constructor(uuid: string, response: RestResponse) {
this.payload = {
uuid,
response
};
}
}
/**
* An ngrx action to reset the timeAdded property of all responses in the cached objects
*/
export class ResetResponseTimestampsAction implements Action {
type = RequestActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetResponseTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/* tslint:enable:max-classes-per-file */
/**
@@ -63,4 +90,5 @@ export class RequestCompleteAction implements Action {
export type RequestAction
= RequestConfigureAction
| RequestExecuteAction
| RequestCompleteAction;
| RequestCompleteAction
| ResetResponseTimestampsAction;

View File

@@ -1,42 +1,48 @@
import { Observable, of as observableOf } from 'rxjs';
import { Inject, Injectable, Injector } from '@angular/core';
import { Request } from '@angular/http';
import { RequestArgs } from '@angular/http/src/interfaces';
import { Actions, Effect, ofType } from '@ngrx/effects';
// tslint:disable-next-line:import-blacklist
import { Observable } from 'rxjs';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { isNotEmpty } from '../../shared/empty.util';
import { ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { ResponseCacheService } from '../cache/response-cache.service';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RequestActionTypes, RequestCompleteAction, RequestExecuteAction } from './request.actions';
import {
RequestActionTypes,
RequestCompleteAction,
RequestExecuteAction,
ResetResponseTimestampsAction
} from './request.actions';
import { RequestError, RestRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory';
import { catchError, flatMap, map, take, tap } from 'rxjs/operators';
import { catchError, filter, flatMap, map, take, tap } from 'rxjs/operators';
import { ErrorResponse, RestResponse } from '../cache/response.models';
import { StoreActionTypes } from '../../store.actions';
export const addToResponseCacheAndCompleteAction = (request: RestRequest, responseCache: ResponseCacheService, envConfig: GlobalConfig) =>
(source: Observable<ErrorResponse>): Observable<RequestCompleteAction> =>
export const addToResponseCacheAndCompleteAction = (request: RestRequest, envConfig: GlobalConfig) =>
(source: Observable<RestResponse>): Observable<RequestCompleteAction> =>
source.pipe(
tap((response: RestResponse) => responseCache.add(request.href, response, envConfig.cache.msToLive)),
map((response: RestResponse) => new RequestCompleteAction(request.uuid))
map((response: RestResponse) => {
return new RequestCompleteAction(request.uuid, response)
})
);
@Injectable()
export class RequestEffects {
@Effect() execute = this.actions$.ofType(RequestActionTypes.EXECUTE).pipe(
@Effect() execute = this.actions$.pipe(
ofType(RequestActionTypes.EXECUTE),
flatMap((action: RequestExecuteAction) => {
return this.requestService.getByUUID(action.payload).pipe(
take(1)
);
}),
filter((entry: RequestEntry) => hasValue(entry)),
map((entry: RequestEntry) => entry.request),
tap((entry: RequestEntry) => console.log(entry)),
flatMap((request: RestRequest) => {
let body;
if (isNotEmpty(request.body)) {
@@ -45,20 +51,32 @@ export class RequestEffects {
}
return this.restApi.request(request.method, request.href, body, request.options).pipe(
map((data: DSpaceRESTV2Response) => this.injector.get(request.getResponseParser()).parse(request, data)),
addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig),
catchError((error: RequestError) => Observable.of(new ErrorResponse(error)).pipe(
addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig)
addToResponseCacheAndCompleteAction(request, this.EnvConfig),
catchError((error: RequestError) => observableOf(new ErrorResponse(error)).pipe(
addToResponseCacheAndCompleteAction(request, this.EnvConfig)
))
);
})
);
/**
* When the store is rehydrated in the browser, set all cache
* timestamps to 'now', because the time zone of the server can
* differ from the client.
*
* This assumes that the server cached everything a negligible
* time ago, and will likely need to be revisited later
*/
@Effect() fixTimestampsOnRehydrate = this.actions$
.pipe(ofType(StoreActionTypes.REHYDRATE),
map(() => new ResetResponseTimestampsAction(new Date().getTime()))
);
constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
private actions$: Actions,
private restApi: DSpaceRESTv2Service,
private injector: Injector,
private responseCache: ResponseCacheService,
protected requestService: RequestService
) { }

View File

@@ -9,51 +9,41 @@ import { ConfigResponseParsingService } from './config-response-parsing.service'
import { AuthResponseParsingService } from '../auth/auth-response-parsing.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
import { RestRequestMethod } from './rest-request-method';
import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service';
/* tslint:disable:max-classes-per-file */
/**
* Represents a Request Method.
*
* I didn't reuse the RequestMethod enum in @angular/http because
* it uses numbers. The string values here are more clear when
* debugging.
*
* The ones commented out are still unsupported in the rest of the codebase
*/
export enum RestRequestMethod {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Delete = 'DELETE',
Options = 'OPTIONS',
Head = 'HEAD',
Patch = 'PATCH'
}
export abstract class RestRequest {
public responseMsToLive = 0;
constructor(
public uuid: string,
public href: string,
public method: RestRequestMethod = RestRequestMethod.Get,
public method: RestRequestMethod = RestRequestMethod.GET,
public body?: any,
public options?: HttpOptions
public options?: HttpOptions,
) {
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return DSOResponseParsingService;
}
get toCache(): boolean {
return this.responseMsToLive > 0;
}
}
export class GetRequest extends RestRequest {
public responseMsToLive = 60 * 15 * 1000;
constructor(
public uuid: string,
public href: string,
public body?: any,
public options?: HttpOptions
public options?: HttpOptions,
) {
super(uuid, href, RestRequestMethod.Get, body)
super(uuid, href, RestRequestMethod.GET, body, options)
}
}
@@ -64,7 +54,7 @@ export class PostRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Post, body)
super(uuid, href, RestRequestMethod.POST, body)
}
}
@@ -75,7 +65,7 @@ export class PutRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Put, body)
super(uuid, href, RestRequestMethod.PUT, body)
}
}
@@ -86,7 +76,7 @@ export class DeleteRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Delete, body)
super(uuid, href, RestRequestMethod.DELETE, body)
}
}
@@ -97,7 +87,7 @@ export class OptionsRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Options, body)
super(uuid, href, RestRequestMethod.OPTIONS, body)
}
}
@@ -108,7 +98,7 @@ export class HeadRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Head, body)
super(uuid, href, RestRequestMethod.HEAD, body)
}
}
@@ -119,7 +109,7 @@ export class PatchRequest extends RestRequest {
public body?: any,
public options?: HttpOptions
) {
super(uuid, href, RestRequestMethod.Patch, body)
super(uuid, href, RestRequestMethod.PATCH, body)
}
}
@@ -181,6 +171,12 @@ export class BrowseEntriesRequest extends GetRequest {
}
}
export class BrowseItemsRequest extends GetRequest {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return BrowseItemsResponseParsingService;
}
}
export class ConfigRequest extends GetRequest {
constructor(uuid: string, href: string) {
super(uuid, href);

View File

@@ -2,16 +2,20 @@ import * as deepFreeze from 'deep-freeze';
import { requestReducer, RequestState } from './request.reducer';
import {
RequestCompleteAction, RequestConfigureAction, RequestExecuteAction
RequestCompleteAction,
RequestConfigureAction,
RequestExecuteAction, ResetResponseTimestampsAction
} from './request.actions';
import { GetRequest, RestRequest } from './request.models';
import { GetRequest } from './request.models';
import { RestResponse } from '../cache/response.models';
const response = new RestResponse(true, 'OK');
class NullAction extends RequestCompleteAction {
type = null;
payload = null;
constructor() {
super(null);
super(null, null);
}
}
@@ -25,7 +29,8 @@ describe('requestReducer', () => {
request: new GetRequest(id1, link1),
requestPending: false,
responsePending: false,
completed: false
completed: false,
response: undefined
}
};
deepFreeze(testState);
@@ -56,6 +61,7 @@ describe('requestReducer', () => {
expect(newState[id2].requestPending).toEqual(true);
expect(newState[id2].responsePending).toEqual(false);
expect(newState[id2].completed).toEqual(false);
expect(newState[id2].response).toEqual(undefined);
});
it('should set \'requestPending\' to false, \'responsePending\' to true and leave \'completed\' untouched for the given RestRequest in the state, in response to an EXECUTE action', () => {
@@ -69,11 +75,13 @@ describe('requestReducer', () => {
expect(newState[id1].requestPending).toEqual(false);
expect(newState[id1].responsePending).toEqual(true);
expect(newState[id1].completed).toEqual(state[id1].completed);
expect(newState[id1].response).toEqual(undefined)
});
it('should leave \'requestPending\' untouched, set \'responsePending\' to false and \'completed\' to true for the given RestRequest in the state, in response to a COMPLETE action', () => {
const state = testState;
const action = new RequestCompleteAction(id1);
const action = new RequestCompleteAction(id1, response);
const newState = requestReducer(state, action);
expect(newState[id1].request.uuid).toEqual(id1);
@@ -81,5 +89,25 @@ describe('requestReducer', () => {
expect(newState[id1].requestPending).toEqual(state[id1].requestPending);
expect(newState[id1].responsePending).toEqual(false);
expect(newState[id1].completed).toEqual(true);
expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful)
expect(newState[id1].response.statusCode).toEqual(response.statusCode)
expect(newState[id1].response.timeAdded).toBeTruthy()
});
it('should leave \'requestPending\' untouched, should leave \'responsePending\' untouched and leave \'completed\' untouched, but update the response\'s timeAdded for the given RestRequest in the state, in response to a COMPLETE action', () => {
const update = Object.assign({}, testState[id1], {response});
const state = Object.assign({}, testState, {[id1]: update});
const timeStamp = 1000;
const action = new ResetResponseTimestampsAction(timeStamp);
const newState = requestReducer(state, action);
expect(newState[id1].request.uuid).toEqual(state[id1].request.uuid);
expect(newState[id1].request.href).toEqual(state[id1].request.href);
expect(newState[id1].requestPending).toEqual(state[id1].requestPending);
expect(newState[id1].responsePending).toEqual(state[id1].responsePending);
expect(newState[id1].completed).toEqual(state[id1].completed);
expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful);
expect(newState[id1].response.statusCode).toEqual(response.statusCode);
expect(newState[id1].response.timeAdded).toBe(timeStamp);
});
});

View File

@@ -1,14 +1,16 @@
import {
RequestActionTypes, RequestAction, RequestConfigureAction,
RequestExecuteAction, RequestCompleteAction
RequestExecuteAction, RequestCompleteAction, ResetResponseTimestampsAction
} from './request.actions';
import { RestRequest } from './request.models';
import { RestResponse } from '../cache/response.models';
export class RequestEntry {
request: RestRequest;
requestPending: boolean;
responsePending: boolean;
completed: boolean;
response: RestResponse
}
export interface RequestState {
@@ -32,6 +34,9 @@ export function requestReducer(state = initialState, action: RequestAction): Req
case RequestActionTypes.COMPLETE: {
return completeRequest(state, action as RequestCompleteAction);
}
case RequestActionTypes.RESET_TIMESTAMPS: {
return resetResponseTimestamps(state, action as ResetResponseTimestampsAction);
}
default: {
return state;
@@ -45,18 +50,19 @@ function configureRequest(state: RequestState, action: RequestConfigureAction):
request: action.payload,
requestPending: true,
responsePending: false,
completed: false
completed: false,
}
});
}
function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState {
return Object.assign({}, state, {
const obs = Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
requestPending: false,
responsePending: true
})
});
return obs;
}
/**
@@ -70,10 +76,22 @@ function executeRequest(state: RequestState, action: RequestExecuteAction): Requ
* the new state, with the response added to the request
*/
function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState {
const time = new Date().getTime();
return Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
[action.payload.uuid]: Object.assign({}, state[action.payload.uuid], {
responsePending: false,
completed: true
completed: true,
response: Object.assign({}, action.payload.response, { timeAdded: time })
})
});
}
function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction) {
const newState = Object.create(null);
Object.keys(state).forEach((key) => {
newState[key] = Object.assign({}, state[key],
{ response: Object.assign({}, state[key].response, { timeAdded: action.payload }) }
);
});
return newState;
}

View File

@@ -1,16 +1,12 @@
import { Store } from '@ngrx/store';
import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { getMockStore } from '../../shared/mocks/mock-store';
import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { CoreState } from '../core.reducers';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction } from './request.actions';
import * as ngrx from '@ngrx/store';
import {
DeleteRequest,
GetRequest,
@@ -18,15 +14,19 @@ import {
OptionsRequest,
PatchRequest,
PostRequest,
PutRequest, RestRequest
PutRequest,
RestRequest
} from './request.models';
import { RequestService } from './request.service';
import { ActionsSubject, Store } from '@ngrx/store';
import { TestScheduler } from 'rxjs/testing';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
describe('RequestService', () => {
let scheduler: TestScheduler;
let service: RequestService;
let serviceAsAny: any;
let objectCache: ObjectCacheService;
let responseCache: ResponseCacheService;
let uuidService: UUIDService;
let store: Store<CoreState>;
@@ -39,23 +39,25 @@ describe('RequestService', () => {
const testOptionsRequest = new OptionsRequest(testUUID, testHref);
const testHeadRequest = new HeadRequest(testUUID, testHref);
const testPatchRequest = new PatchRequest(testUUID, testHref);
let selectSpy;
beforeEach(() => {
scheduler = getTestScheduler();
objectCache = getMockObjectCacheService();
(objectCache.hasBySelfLink as any).and.returnValue(false);
responseCache = getMockResponseCacheService();
(responseCache.has as any).and.returnValue(false);
(responseCache.get as any).and.returnValue(Observable.of(undefined));
uuidService = getMockUUIDService();
store = getMockStore<CoreState>();
(store.select as any).and.returnValue(Observable.of(undefined));
store = new Store<CoreState>(new BehaviorSubject({}), new ActionsSubject(), null);
selectSpy = spyOnProperty(ngrx, 'select');
selectSpy.and.callFake(() => {
return () => {
return () => cold('a', { a: undefined });
};
});
service = new RequestService(
objectCache,
responseCache,
uuidService,
store
);
@@ -74,7 +76,7 @@ describe('RequestService', () => {
describe('isPending', () => {
describe('before the request is configured', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined));
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
});
it('should return false', () => {
@@ -87,7 +89,7 @@ describe('RequestService', () => {
describe('when the request has been configured but hasn\'t reached the store yet', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined));
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
serviceAsAny.requestsOnTheirWayToTheStore = [testHref];
});
@@ -101,7 +103,7 @@ describe('RequestService', () => {
describe('when the request has reached the store, before the server responds', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(Observable.of({
spyOn(service, 'getByHref').and.returnValue(observableOf({
completed: false
}))
});
@@ -116,7 +118,7 @@ describe('RequestService', () => {
describe('after the server responds', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValues(Observable.of({
spyOn(service, 'getByHref').and.returnValues(observableOf({
completed: true
}));
});
@@ -134,11 +136,15 @@ describe('RequestService', () => {
describe('getByUUID', () => {
describe('if the request with the specified UUID exists in the store', () => {
beforeEach(() => {
(store.select as any).and.returnValues(hot('a', {
a: {
completed: true
}
}));
selectSpy.and.callFake(() => {
return () => {
return () => hot('a', {
a: {
completed: true
}
});
};
});
});
it('should return an Observable of the RequestEntry', () => {
@@ -155,18 +161,20 @@ describe('RequestService', () => {
describe('if the request with the specified UUID doesn\'t exist in the store', () => {
beforeEach(() => {
(store.select as any).and.returnValues(hot('a', {
a: undefined
}));
selectSpy.and.callFake(() => {
return () => {
return () => hot('a', { a: undefined });
};
});
});
it('should return an Observable of undefined', () => {
const result = service.getByUUID(testUUID);
const expected = cold('b', {
b: undefined
});
// const expected = cold('b', {
// b: undefined
// });
expect(result).toBeObservable(expected);
scheduler.expectObservable(result).toBe('b', {b: undefined});
});
});
@@ -175,9 +183,11 @@ describe('RequestService', () => {
describe('getByHref', () => {
describe('when the request with the specified href exists in the store', () => {
beforeEach(() => {
(store.select as any).and.returnValues(hot('a', {
a: testUUID
}));
selectSpy.and.callFake(() => {
return () => {
return () => hot('a', { a: testUUID });
};
});
spyOn(service, 'getByUUID').and.returnValue(cold('b', {
b: {
completed: true
@@ -199,9 +209,11 @@ describe('RequestService', () => {
describe('when the request with the specified href doesn\'t exist in the store', () => {
beforeEach(() => {
(store.select as any).and.returnValues(hot('a', {
a: undefined
}));
selectSpy.and.callFake(() => {
return () => {
return () => hot('a', { a: undefined });
};
});
spyOn(service, 'getByUUID').and.returnValue(cold('b', {
b: undefined
}));
@@ -241,7 +253,8 @@ describe('RequestService', () => {
});
it('should dispatch the request', () => {
service.configure(request);
scheduler.schedule(() => service.configure(request));
scheduler.flush();
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request);
});
});
@@ -306,7 +319,7 @@ describe('RequestService', () => {
describe('when the request is cached', () => {
describe('in the ObjectCache', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValues(true);
(objectCache.hasBySelfLink as any).and.returnValue(true);
});
it('should return true', () => {
@@ -318,12 +331,13 @@ describe('RequestService', () => {
});
describe('in the responseCache', () => {
beforeEach(() => {
(responseCache.has as any).and.returnValues(true);
spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true));
spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined));
});
describe('and it\'s a DSOSuccessResponse', () => {
beforeEach(() => {
(responseCache.get as any).and.returnValues(Observable.of({
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true,
resourceSelfLinks: [
@@ -345,6 +359,7 @@ describe('RequestService', () => {
});
it('should return false if not all top level links in the response are cached in the object cache', () => {
(objectCache.hasBySelfLink as any).and.returnValues(false, true, false);
spyOn(service, 'isPending').and.returnValue(false);
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = false;
@@ -352,11 +367,12 @@ describe('RequestService', () => {
expect(result).toEqual(expected);
});
});
describe('and it isn\'t a DSOSuccessResponse', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValues(false);
(responseCache.has as any).and.returnValues(true);
(responseCache.get as any).and.returnValues(Observable.of({
(objectCache.hasBySelfLink as any).and.returnValue(false);
(service as any).isReusable.and.returnValue(observableOf(true));
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true
}
@@ -398,6 +414,10 @@ describe('RequestService', () => {
});
describe('dispatchRequest', () => {
beforeEach(() => {
spyOn(store, 'dispatch');
});
it('should dispatch a RequestConfigureAction', () => {
const request = testGetRequest;
serviceAsAny.dispatchRequest(request);
@@ -428,7 +448,11 @@ describe('RequestService', () => {
describe('when the request is added to the store', () => {
it('should stop tracking the request', () => {
(store.select as any).and.returnValues(Observable.of({ request }));
selectSpy.and.callFake(() => {
return () => {
return () => observableOf({ request });
};
});
serviceAsAny.trackRequestsOnTheirWayToTheStore(request);
expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeFalsy();
});

View File

@@ -1,30 +1,43 @@
import { merge as observableMerge, Observable, of as observableOf } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
first,
map,
mergeMap,
reduce,
startWith,
switchMap,
take,
tap
} from 'rxjs/operators';
import { race as observableRace } from 'rxjs';
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { hasValue } from '../../shared/empty.util';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { hasNoValue, hasValue, isNotUndefined } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
import { coreSelector, CoreState } from '../core.reducers';
import { IndexName } from '../index/index.reducer';
import { pathSelector } from '../shared/selectors';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction } from './request.actions';
import { GetRequest, RestRequest, RestRequestMethod } from './request.models';
import { GetRequest, RestRequest } from './request.models';
import { RequestEntry, RequestState } from './request.reducer';
import { ResponseCacheRemoveAction } from '../cache/response-cache.actions';
import { RequestEntry } from './request.reducer';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { getResponseFromEntry } from '../shared/operators';
import { AddToIndexAction } from '../index/index.actions';
@Injectable()
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
constructor(private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>) {
}
@@ -37,6 +50,10 @@ export class RequestService {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.REQUEST, href);
}
private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.UUID_MAPPING, uuid);
}
generateRequestId(): string {
return `client/${this.uuidService.generate()}`;
}
@@ -49,8 +66,8 @@ export class RequestService {
// then check the store
let isPending = false;
this.getByHref(request.href)
.take(1)
this.getByHref(request.href).pipe(
take(1))
.subscribe((re: RequestEntry) => {
isPending = (hasValue(re) && !re.completed)
});
@@ -59,51 +76,69 @@ export class RequestService {
}
getByUUID(uuid: string): Observable<RequestEntry> {
return this.store.select(this.entryFromUUIDSelector(uuid));
return observableRace(
this.store.pipe(select(this.entryFromUUIDSelector(uuid))),
this.store.pipe(
select(this.originalUUIDFromUUIDSelector(uuid)),
switchMap((originalUUID) => {
return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID)))
},
))
);
}
getByHref(href: string): Observable<RequestEntry> {
return this.store.select(this.uuidFromHrefSelector(href))
.flatMap((uuid: string) => this.getByUUID(uuid));
return this.store.pipe(
select(this.uuidFromHrefSelector(href)),
mergeMap((uuid: string) => this.getByUUID(uuid))
);
}
// TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
configure<T extends CacheableObject>(request: RestRequest, forceBypassCache: boolean = false): void {
const isGetRequest = request.method === RestRequestMethod.Get;
const isGetRequest = request.method === RestRequestMethod.GET;
if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) {
this.dispatchRequest(request);
if (isGetRequest && !forceBypassCache) {
this.trackRequestsOnTheirWayToTheStore(request);
}
} else {
this.getByHref(request.href).pipe(
filter((entry) => hasValue(entry)),
take(1)
).subscribe((entry) => {
return this.store.dispatch(new AddToIndexAction(IndexName.UUID_MAPPING, request.uuid, entry.request.uuid))
}
)
}
}
private isCachedOrPending(request: GetRequest) {
let isCached = this.objectCache.hasBySelfLink(request.href);
if (!isCached && this.responseCache.has(request.href)) {
const [successResponse, errorResponse] = this.responseCache.get(request.href)
.take(1)
.map((entry: ResponseCacheEntry) => entry.response)
.share()
.partition((response: RestResponse) => response.isSuccessful);
if (isCached) {
const responses: Observable<RestResponse> = this.isReusable(request.uuid).pipe(
filter((reusable: boolean) => reusable),
switchMap(() => {
return this.getByHref(request.href).pipe(
getResponseFromEntry(),
take(1)
);
}
));
const [dsoSuccessResponse, otherSuccessResponse] = successResponse
.share()
.partition((response: DSOSuccessResponse) => hasValue(response.resourceSelfLinks));
const errorResponses = responses.pipe(filter((response) => !response.isSuccessful), map(() => true)); // TODO add a configurable number of retries in case of an error.
const dsoSuccessResponses = responses.pipe(
filter((response) => response.isSuccessful && hasValue((response as DSOSuccessResponse).resourceSelfLinks)),
map((response: DSOSuccessResponse) => response.resourceSelfLinks),
map((resourceSelfLinks: string[]) => resourceSelfLinks
.every((selfLink) => this.objectCache.hasBySelfLink(selfLink))
));
Observable.merge(
errorResponse.map(() => true), // TODO add a configurable number of retries in case of an error.
otherSuccessResponse.map(() => true),
dsoSuccessResponse // a DSOSuccessResponse should only be considered cached if all its resources are cached
.map((response: DSOSuccessResponse) => response.resourceSelfLinks)
.map((resourceSelfLinks: string[]) => resourceSelfLinks
.every((selfLink) => this.objectCache.hasBySelfLink(selfLink))
)
).subscribe((c) => isCached = c);
const otherSuccessResponses = responses.pipe(filter((response) => response.isSuccessful && !hasValue((response as DSOSuccessResponse).resourceSelfLinks)), map(() => true));
observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c);
}
const isPending = this.isPending(request);
return isCached || isPending;
}
@@ -121,11 +156,45 @@ export class RequestService {
*/
private trackRequestsOnTheirWayToTheStore(request: GetRequest) {
this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href];
this.store.select(this.entryFromUUIDSelector(request.href))
.filter((re: RequestEntry) => hasValue(re))
.take(1)
.subscribe((re: RequestEntry) => {
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href)
});
this.store.pipe(select(this.entryFromUUIDSelector(request.href)),
filter((re: RequestEntry) => hasValue(re)),
take(1)
).subscribe((re: RequestEntry) => {
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href)
});
}
commit(method?: RestRequestMethod) {
this.store.dispatch(new CommitSSBAction(method))
}
/**
* Check whether a Response should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isReusable(uuid: string): Observable<boolean> {
if (hasNoValue(uuid)) {
return observableOf(false);
} else {
const requestEntry$ = this.getByUUID(uuid);
return requestEntry$.pipe(
filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)),
map((entry: RequestEntry) => {
if (hasValue(entry) && entry.response.isSuccessful) {
const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive;
const isOutDated = new Date().getTime() > timeOutdated;
return !isOutDated;
} else {
return false;
}
})
);
return observableOf(false);
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* Represents a Request Method.
*
* I didn't reuse the RequestMethod enum in @angular/http because
* it uses numbers. The string values here are more clear when
* debugging.
*
* The ones commented out are still unsupported in the rest of the codebase
*/
export enum RestRequestMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
OPTIONS = 'OPTIONS',
HEAD = 'HEAD',
PATCH = 'PATCH'
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { RestResponse, SearchSuccessResponse } from '../cache/response-cache.models';
import { RestResponse, SearchSuccessResponse } from '../cache/response.models';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';