Merge pull request #196 from 4Science/configuration

Configuration endpoints
This commit is contained in:
Art Lowel
2017-11-28 13:01:59 +01:00
committed by GitHub
22 changed files with 872 additions and 136 deletions

View File

@@ -1,6 +1,7 @@
import { RequestError } from '../data/request.models'; import { RequestError } from '../data/request.models';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { ConfigObject } from '../shared/config/config.model';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export class RestResponse { export class RestResponse {
@@ -51,4 +52,14 @@ export class ErrorResponse extends RestResponse {
this.errorMessage = error.message; this.errorMessage = error.message;
} }
} }
export class ConfigSuccessResponse extends RestResponse {
constructor(
public configDefinition: ConfigObject[],
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
}
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */

View File

@@ -0,0 +1,13 @@
import { PageInfo } from '../shared/page-info.model';
import { ConfigObject } from '../shared/config/config.model';
/**
* A class to represent the data retrieved by a configuration service
*/
export class ConfigData {
constructor(
public pageInfo: PageInfo,
public payload: ConfigObject[]
) {
}
}

View File

@@ -0,0 +1,103 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/Rx';
import { GlobalConfig } from '../../../config';
import { ResponseCacheService } from '../cache/response-cache.service';
import { ConfigService } from './config.service';
import { RequestService } from '../data/request.service';
import { ConfigRequest, FindAllOptions } from '../data/request.models';
const LINK_NAME = 'test';
const BROWSE = 'search/findByCollection';
class TestService extends ConfigService {
protected linkName = LINK_NAME;
protected browseEndpoint = BROWSE;
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected EnvConfig: GlobalConfig
) {
super();
}
}
describe('ConfigService', () => {
let scheduler: TestScheduler;
let service: TestService;
let responseCache: ResponseCacheService;
let requestService: RequestService;
const envConfig = {} as GlobalConfig;
const findOptions: FindAllOptions = new FindAllOptions();
const scopeName = 'traditional';
const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
const configEndpoint = 'https://rest.api/config';
const serviceEndpoint = `${configEndpoint}/${LINK_NAME}`;
const scopedEndpoint = `${serviceEndpoint}/${scopeName}`;
const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`;
function initMockRequestService(): RequestService {
return jasmine.createSpyObj('requestService', ['configure']);
}
function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService {
return jasmine.createSpyObj('responseCache', {
get: cold('c-', {
c: { response: { isSuccessful } }
})
});
}
function initTestService(): TestService {
return new TestService(
responseCache,
requestService,
envConfig
);
}
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = initMockRequestService();
service = initTestService();
scheduler = getTestScheduler();
spyOn(service, 'getEndpoint').and
.returnValue(hot('--a-', { a: serviceEndpoint }));
});
describe('getConfigByHref', () => {
it('should configure a new ConfigRequest', () => {
const expected = new ConfigRequest(scopedEndpoint);
scheduler.schedule(() => service.getConfigByHref(scopedEndpoint).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
describe('getConfigByName', () => {
it('should configure a new ConfigRequest', () => {
const expected = new ConfigRequest(scopedEndpoint);
scheduler.schedule(() => service.getConfigByName(scopeName).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
describe('getConfigBySearch', () => {
it('should configure a new ConfigRequest', () => {
findOptions.scopeID = scopeID;
const expected = new ConfigRequest(searchEndpoint);
scheduler.schedule(() => service.getConfigBySearch(findOptions).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
});

View File

@@ -0,0 +1,113 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { RequestService } from '../data/request.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ConfigData } from './config-data';
export abstract class ConfigService extends HALEndpointService {
protected request: ConfigRequest;
protected abstract responseCache: ResponseCacheService;
protected abstract requestService: RequestService;
protected abstract linkName: string;
protected abstract EnvConfig: GlobalConfig;
protected abstract browseEndpoint: string;
protected getConfig(request: RestRequest): Observable<ConfigData> {
const [successResponse, errorResponse] = this.responseCache.get(request.href)
.map((entry: ResponseCacheEntry) => entry.response)
.partition((response: RestResponse) => response.isSuccessful);
return Observable.merge(
errorResponse.flatMap((response: ErrorResponse) =>
Observable.throw(new Error(`Couldn't retrieve the config`))),
successResponse
.filter((response: ConfigSuccessResponse) => isNotEmpty(response) && isNotEmpty(response.configDefinition))
.map((response: ConfigSuccessResponse) => new ConfigData(response.pageInfo, response.configDefinition))
.distinctUntilChanged());
}
protected getConfigByNameHref(endpoint, resourceName): string {
return `${endpoint}/${resourceName}`;
}
protected getConfigSearchHref(endpoint, options: FindAllOptions = {}): string {
let result;
const args = [];
if (hasValue(options.scopeID)) {
result = `${endpoint}/${this.browseEndpoint}`;
args.push(`uuid=${options.scopeID}`);
} else {
result = endpoint;
}
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)) {
let direction = 'asc';
if (options.sort.direction === 1) {
direction = 'desc';
}
args.push(`sort=${options.sort.field},${direction}`);
}
if (isNotEmpty(args)) {
result = `${result}?${args.join('&')}`;
}
return result;
}
public getConfigAll(): Observable<ConfigData> {
return this.getEndpoint()
.filter((href: string) => isNotEmpty(href))
.distinctUntilChanged()
.map((endpointURL: string) => new ConfigRequest(endpointURL))
.do((request: RestRequest) => this.requestService.configure(request))
.flatMap((request: RestRequest) => this.getConfig(request))
.distinctUntilChanged();
}
public getConfigByHref(href: string): Observable<ConfigData> {
const request = new ConfigRequest(href);
this.requestService.configure(request);
return this.getConfig(request);
}
public getConfigByName(name: string): Observable<ConfigData> {
return this.getEndpoint()
.map((endpoint: string) => this.getConfigByNameHref(endpoint, name))
.filter((href: string) => isNotEmpty(href))
.distinctUntilChanged()
.map((endpointURL: string) => new ConfigRequest(endpointURL))
.do((request: RestRequest) => this.requestService.configure(request))
.flatMap((request: RestRequest) => this.getConfig(request))
.distinctUntilChanged();
}
public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> {
return this.getEndpoint()
.map((endpoint: string) => this.getConfigSearchHref(endpoint, options))
.filter((href: string) => isNotEmpty(href))
.distinctUntilChanged()
.map((endpointURL: string) => new ConfigRequest(endpointURL))
.do((request: RestRequest) => this.requestService.configure(request))
.flatMap((request: RestRequest) => this.getConfig(request))
.distinctUntilChanged();
}
}

View File

@@ -0,0 +1,21 @@
import { Inject, Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
@Injectable()
export class SubmissionDefinitionsConfigService extends ConfigService {
protected linkName = 'submissiondefinitions';
protected browseEndpoint = 'search/findByCollection';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { Inject, Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
@Injectable()
export class SubmissionFormsConfigService extends ConfigService {
protected linkName = 'submissionforms';
protected browseEndpoint = '';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { Inject, Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
@Injectable()
export class SubmissionSectionsConfigService extends ConfigService {
protected linkName = 'submissionsections';
protected browseEndpoint = '';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
super();
}
}

View File

@@ -32,7 +32,11 @@ import { ServerResponseService } from '../shared/server-response.service';
import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/window.service';
import { BrowseService } from './browse/browse.service'; import { BrowseService } from './browse/browse.service';
import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service';
import { ConfigResponseParsingService } from './data/config-response-parsing.service';
import { RouteService } from '../shared/route.service'; import { RouteService } from '../shared/route.service';
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -66,7 +70,11 @@ const PROVIDERS = [
ServerResponseService, ServerResponseService,
BrowseResponseParsingService, BrowseResponseParsingService,
BrowseService, BrowseService,
ConfigResponseParsingService,
RouteService, RouteService,
SubmissionDefinitionsConfigService,
SubmissionFormsConfigService,
SubmissionSectionsConfigService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory } { provide: NativeWindowService, useFactory: NativeWindowFactory }
]; ];

View File

@@ -0,0 +1,135 @@
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { CacheableObject } from '../cache/object-cache.reducer';
import { PageInfo } from '../shared/page-info.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { GenericConstructor } from '../shared/generic-constructor';
function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
}
function isPaginatedResponse(halObj: any) {
return isNotEmpty(halObj.page) && hasValue(halObj._embedded);
}
/* tslint:disable:max-classes-per-file */
class ProcessRequestDTO<ObjectDomain> {
[key: string]: ObjectDomain[]
}
export abstract class BaseResponseParsingService {
protected abstract EnvConfig: GlobalConfig;
protected abstract objectCache: ObjectCacheService;
protected abstract objectFactory: any;
protected abstract toCache: boolean;
protected process<ObjectDomain,ObjectType>(data: any, requestHref: string): ProcessRequestDTO<ObjectDomain> {
if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) {
return this.process(data._embedded, requestHref);
} else if (isObjectLevel(data)) {
return { topLevel: this.deserializeAndCache(data, requestHref) };
} else {
const result = new ProcessRequestDTO<ObjectDomain>();
if (Array.isArray(data)) {
result.topLevel = [];
data.forEach((datum) => {
if (isPaginatedResponse(datum)) {
const obj = this.process(datum, requestHref);
result.topLevel = [...result.topLevel, ...this.flattenSingleKeyObject(obj)];
} else {
result.topLevel = [...result.topLevel, ...this.deserializeAndCache<ObjectDomain,ObjectType>(datum, requestHref)];
}
});
} else {
Object.keys(data)
.filter((property) => data.hasOwnProperty(property))
.filter((property) => hasValue(data[property]))
.forEach((property) => {
if (isPaginatedResponse(data[property])) {
const obj = this.process(data[property], requestHref);
result[property] = this.flattenSingleKeyObject(obj);
} else {
result[property] = this.deserializeAndCache(data[property], requestHref);
}
});
}
return result;
}
}
}
protected deserializeAndCache<ObjectDomain,ObjectType>(obj, requestHref: string): ObjectDomain[] {
if (Array.isArray(obj)) {
let result = [];
obj.forEach((o) => result = [...result, ...this.deserializeAndCache<ObjectDomain,ObjectType>(o, requestHref)]);
return result;
}
const type: ObjectType = obj.type;
if (hasValue(type)) {
const normObjConstructor = this.objectFactory.getConstructor(type) as GenericConstructor<ObjectDomain>;
if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
let processed;
if (isNotEmpty(obj._embedded)) {
processed = this.process<ObjectDomain,ObjectType>(obj._embedded, requestHref);
}
const normalizedObj: any = serializer.deserialize(obj);
if (isNotEmpty(processed)) {
const processedList = {};
Object.keys(processed).forEach((key) => {
processedList[key] = processed[key].map((no: NormalizedObject) => (this.toCache) ? no.self : no);
});
Object.assign(normalizedObj, processedList);
}
if (this.toCache) {
this.addToObjectCache(normalizedObj, requestHref);
}
return [normalizedObj] as any;
} else {
// TODO: move check to Validator?
// throw new Error(`The server returned an object with an unknown a known type: ${type}`);
return [];
}
} else {
// TODO: move check to Validator
// throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
return [];
}
}
protected addToObjectCache(co: CacheableObject, requestHref: string): void {
if (hasNoValue(co) || hasNoValue(co.self)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref);
}
protected processPageInfo(pageObj: any): PageInfo {
if (isNotEmpty(pageObj)) {
return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj);
} else {
return undefined;
}
}
protected flattenSingleKeyObject(obj: any): any {
const keys = Object.keys(obj);
if (keys.length !== 1) {
throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`);
}
return obj[keys[0]];
}
}

View File

@@ -66,7 +66,7 @@ describe('ComColDataService', () => {
return jasmine.createSpyObj('requestService', ['configure']); return jasmine.createSpyObj('requestService', ['configure']);
} }
function initMockResponceCacheService(isSuccessful: boolean): ResponseCacheService { function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService {
return jasmine.createSpyObj('responseCache', { return jasmine.createSpyObj('responseCache', {
get: cold('c-', { get: cold('c-', {
c: { response: { isSuccessful } } c: { response: { isSuccessful } }
@@ -107,7 +107,7 @@ describe('ComColDataService', () => {
cds = initMockCommunityDataService(); cds = initMockCommunityDataService();
requestService = initMockRequestService(); requestService = initMockRequestService();
objectCache = initMockObjectCacheService(); objectCache = initMockObjectCacheService();
responseCache = initMockResponceCacheService(true); responseCache = initMockResponseCacheService(true);
service = initTestService(); service = initTestService();
const expected = new FindByIDRequest(communityEndpoint, scopeID); const expected = new FindByIDRequest(communityEndpoint, scopeID);
@@ -123,7 +123,7 @@ describe('ComColDataService', () => {
cds = initMockCommunityDataService(); cds = initMockCommunityDataService();
requestService = initMockRequestService(); requestService = initMockRequestService();
objectCache = initMockObjectCacheService(); objectCache = initMockObjectCacheService();
responseCache = initMockResponceCacheService(true); responseCache = initMockResponseCacheService(true);
service = initTestService(); service = initTestService();
}); });
@@ -146,7 +146,7 @@ describe('ComColDataService', () => {
cds = initMockCommunityDataService(); cds = initMockCommunityDataService();
requestService = initMockRequestService(); requestService = initMockRequestService();
objectCache = initMockObjectCacheService(); objectCache = initMockObjectCacheService();
responseCache = initMockResponceCacheService(false); responseCache = initMockResponseCacheService(false);
service = initTestService(); service = initTestService();
}); });
@@ -163,12 +163,12 @@ describe('ComColDataService', () => {
cds = initMockCommunityDataService(); cds = initMockCommunityDataService();
requestService = initMockRequestService(); requestService = initMockRequestService();
objectCache = initMockObjectCacheService(); objectCache = initMockObjectCacheService();
responseCache = initMockResponceCacheService(true); responseCache = initMockResponseCacheService(true);
service = initTestService(); service = initTestService();
}); });
it('should return this.getEndpoint()', () => { it('should return this.getEndpoint()', () => {
spyOn(service, 'getEndpoint').and.returnValue(cold('--e-', { e: serviceEndpoint })) spyOn(service, 'getEndpoint').and.returnValue(cold('--e-', { e: serviceEndpoint }));
const result = service.getScopedEndpoint(undefined); const result = service.getScopedEndpoint(undefined);
const expected = cold('--f-', { f: serviceEndpoint }); const expected = cold('--f-', { f: serviceEndpoint });

View File

@@ -0,0 +1,240 @@
import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { ConfigResponseParsingService } from './config-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { ConfigRequest } from './request.models';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model';
import { SubmissionSectionModel } from '../shared/config/config-submission-section.model';
describe('ConfigResponseParsingService', () => {
let service: ConfigResponseParsingService;
const EnvConfig = {} as GlobalConfig;
const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store);
beforeEach(() => {
service = new ConfigResponseParsingService(EnvConfig, objectCacheService);
});
describe('parse', () => {
const validRequest = new ConfigRequest('https://rest.api/config/submissiondefinitions/traditional');
const validResponse = {
payload: {
id:'traditional',
name:'traditional',
type:'submissiondefinition',
isDefault:true,
_links:{
sections:{
href:'https://rest.api/config/submissiondefinitions/traditional/sections'
},self:{
href:'https://rest.api/config/submissiondefinitions/traditional'
}
},
_embedded:{
sections:{
page:{
number:0,
size:4,
totalPages:1,totalElements:4
},
_embedded:[
{
id:'traditionalpageone',header:'submit.progressbar.describe.stepone',
mandatory:true,
sectionType:'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/traditionalpageone'
},
config:{
href:'https://rest.api/config/submissionforms/traditionalpageone'
}
}
}, {
id:'traditionalpagetwo',
header:'submit.progressbar.describe.steptwo',
mandatory:true,
sectionType:'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/traditionalpagetwo'
},
config:{
href:'https://rest.api/config/submissionforms/traditionalpagetwo'
}
}
}, {
id:'upload',
header:'submit.progressbar.upload',
mandatory:false,
sectionType:'upload',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/upload'
},
config: {
href:'https://rest.api/config/submissionuploads/upload'
}
}
}, {
id:'license',
header:'submit.progressbar.license',
mandatory:true,
sectionType:'license',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/license'
}
}
}
],
_links:{
self:'https://rest.api/config/submissiondefinitions/traditional/sections'
}
}
}
},
statusCode:'200'
};
const invalidResponse1 = {
payload: {},
statusCode:'200'
};
const invalidResponse2 = {
payload: {
id:'traditional',
name:'traditional',
type:'submissiondefinition',
isDefault:true,
_links:{},
_embedded:{
sections:{
page:{
number:0,
size:4,
totalPages:1,totalElements:4
},
_embedded:[{},{}],
_links:{
self:'https://rest.api/config/submissiondefinitions/traditional/sections'
}
}
}
},
statusCode:'200'
};
const invalidResponse3 = {
payload: {
_links: { self: { href: 'https://rest.api/config/submissiondefinitions/traditional' } },
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '500'
};
const definitions = [
Object.assign(new SubmissionDefinitionsModel(), {
isDefault: true,
name: 'traditional',
type: 'submissiondefinition',
_links: {},
sections: [
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
})
]
})
];
it('should return a ConfigSuccessResponse if data contains a valid config endpoint response', () => {
const response = service.parse(validRequest, validResponse);
expect(response.constructor).toBe(ConfigSuccessResponse);
});
it('should return an ErrorResponse if data contains an invalid config endpoint response', () => {
const response1 = service.parse(validRequest, invalidResponse1);
const response2 = service.parse(validRequest, invalidResponse2);
expect(response1.constructor).toBe(ErrorResponse);
expect(response2.constructor).toBe(ErrorResponse);
});
it('should return an ErrorResponse if data contains a statuscode other than 200', () => {
const response = service.parse(validRequest, invalidResponse3);
expect(response.constructor).toBe(ErrorResponse);
});
it('should return a ConfigSuccessResponse with the ConfigDefinitions in data', () => {
const response = service.parse(validRequest, validResponse);
expect((response as any).configDefinition).toEqual(definitions);
});
});
});

View File

@@ -0,0 +1,43 @@
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 { isNotEmpty } from '../../shared/empty.util';
import { ConfigObjectFactory } from '../shared/config/config-object-factory';
import { ConfigObject } from '../shared/config/config.model';
import { ConfigType } from '../shared/config/config-type';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { ObjectCacheService } from '../cache/object-cache.service';
@Injectable()
export class ConfigResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = ConfigObjectFactory;
protected toCache = false;
constructor(
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected objectCache: ObjectCacheService,
) { super();
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') {
const configDefinition = this.process<ConfigObject,ConfigType>(data.payload, request.href);
return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload.page));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from config endpoint'),
{statusText: data.statusCode}
)
);
}
}
}

View File

@@ -1,149 +1,34 @@
import { ObjectCacheService } from '../cache/object-cache.service';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';
import { NormalizedObject } from '../cache/models/normalized-object.model'; import { NormalizedObject } from '../cache/models/normalized-object.model';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { ResourceType } from '../shared/resource-type'; import { ResourceType } from '../shared/resource-type';
import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { CacheableObject } from '../cache/object-cache.reducer';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; 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-cache.models';
import { RestRequest } from './request.models'; import { RestRequest } from './request.models';
import { PageInfo } from '../shared/page-info.model';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
import { BaseResponseParsingService } from './base-response-parsing.service';
function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
}
function isPaginatedResponse(halObj: any) {
return isNotEmpty(halObj.page) && hasValue(halObj._embedded);
}
function flattenSingleKeyObject(obj: any): any {
const keys = Object.keys(obj);
if (keys.length !== 1) {
throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`);
}
return obj[keys[0]];
}
/* tslint:disable:max-classes-per-file */
class ProcessRequestDTO {
[key: string]: NormalizedObject[]
}
@Injectable() @Injectable()
export class DSOResponseParsingService implements ResponseParsingService { export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = NormalizedObjectFactory;
protected toCache = true;
constructor( constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
private objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
) { ) { super();
} }
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const processRequestDTO = this.process(data.payload, request.href); const processRequestDTO = this.process<NormalizedObject,ResourceType>(data.payload, request.href);
const selfLinks = flattenSingleKeyObject(processRequestDTO).map((no) => no.self); const selfLinks = this.flattenSingleKeyObject(processRequestDTO).map((no) => no.self);
return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload.page)) return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload.page))
} }
protected process(data: any, requestHref: string): ProcessRequestDTO {
if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) {
return this.process(data._embedded, requestHref);
} else if (isObjectLevel(data)) {
return { topLevel: this.deserializeAndCache(data, requestHref) };
} else {
const result = new ProcessRequestDTO();
if (Array.isArray(data)) {
result.topLevel = [];
data.forEach((datum) => {
if (isPaginatedResponse(datum)) {
const obj = this.process(datum, requestHref);
result.topLevel = [...result.topLevel, ...flattenSingleKeyObject(obj)];
} else {
result.topLevel = [...result.topLevel, ...this.deserializeAndCache(datum, requestHref)];
} }
});
} else {
Object.keys(data)
.filter((property) => data.hasOwnProperty(property))
.filter((property) => hasValue(data[property]))
.forEach((property) => {
if (isPaginatedResponse(data[property])) {
const obj = this.process(data[property], requestHref);
result[property] = flattenSingleKeyObject(obj);
} else {
result[property] = this.deserializeAndCache(data[property], requestHref);
}
});
}
return result;
}
}
}
protected deserializeAndCache(obj, requestHref: string): NormalizedObject[] {
if (Array.isArray(obj)) {
let result = [];
obj.forEach((o) => result = [...result, ...this.deserializeAndCache(o, requestHref)])
return result;
}
const type: ResourceType = obj.type;
if (hasValue(type)) {
const normObjConstructor = NormalizedObjectFactory.getConstructor(type);
if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
let processed;
if (isNotEmpty(obj._embedded)) {
processed = this.process(obj._embedded, requestHref);
}
const normalizedObj = serializer.deserialize(obj);
if (isNotEmpty(processed)) {
const linksOnly = {};
Object.keys(processed).forEach((key) => {
linksOnly[key] = processed[key].map((no: NormalizedObject) => no.self);
});
Object.assign(normalizedObj, linksOnly);
}
this.addToObjectCache(normalizedObj, requestHref);
return [normalizedObj];
} else {
// TODO: move check to Validator?
// throw new Error(`The server returned an object with an unknown a known type: ${type}`);
return [];
}
} else {
// TODO: move check to Validator
// throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
return [];
}
}
protected addToObjectCache(co: CacheableObject, requestHref: string): void {
if (hasNoValue(co) || hasNoValue(co.self)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref);
}
protected processPageInfo(pageObj: any): PageInfo {
if (isNotEmpty(pageObj)) {
return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj);
} else {
return undefined;
}
}
}
/* tslint:enable:max-classes-per-file */

View File

@@ -6,6 +6,7 @@ import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
import { RootResponseParsingService } from './root-response-parsing.service'; import { RootResponseParsingService } from './root-response-parsing.service';
import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseResponseParsingService } from './browse-response-parsing.service';
import { ConfigResponseParsingService } from './config-response-parsing.service';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export class RestRequest { export class RestRequest {
@@ -64,6 +65,16 @@ export class BrowseEndpointRequest extends RestRequest {
} }
} }
export class ConfigRequest extends RestRequest {
constructor(href: string) {
super(href);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return ConfigResponseParsingService;
}
}
export class RequestError extends Error { export class RequestError extends Error {
statusText: string; statusText: string;
} }

View File

@@ -26,7 +26,6 @@ export function requestStateSelector(): MemoizedSelector<CoreState, RequestState
}); });
} }
@Injectable() @Injectable()
export class RequestService { export class RequestService {
private requestsOnTheirWayToTheStore: string[] = []; private requestsOnTheirWayToTheStore: string[] = [];

View File

@@ -1,5 +1,6 @@
export interface DSpaceRESTV2Response { export interface DSpaceRESTV2Response {
payload: { payload: {
[name: string]: string;
_embedded?: any; _embedded?: any;
_links?: any; _links?: any;
page?: any; page?: any;

View File

@@ -0,0 +1,30 @@
import { GenericConstructor } from '../../shared/generic-constructor';
import { SubmissionSectionModel } from './config-submission-section.model';
import { SubmissionFormsModel } from './config-submission-forms.model';
import { SubmissionDefinitionsModel } from './config-submission-definitions.model';
import { ConfigType } from './config-type';
import { ConfigObject } from './config.model';
export class ConfigObjectFactory {
public static getConstructor(type): GenericConstructor<ConfigObject> {
switch (type) {
case ConfigType.SubmissionDefinition:
case ConfigType.SubmissionDefinitions: {
return SubmissionDefinitionsModel
}
case ConfigType.SubmissionForm:
case ConfigType.SubmissionForms: {
return SubmissionFormsModel
}
case ConfigType.SubmissionSection:
case ConfigType.SubmissionSections: {
return SubmissionSectionModel
}
default: {
return undefined;
}
}
}
}

View File

@@ -0,0 +1,14 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
import { SubmissionSectionModel } from './config-submission-section.model';
@inheritSerialization(ConfigObject)
export class SubmissionDefinitionsModel extends ConfigObject {
@autoserialize
isDefault: boolean;
@autoserializeAs(SubmissionSectionModel)
sections: SubmissionSectionModel[];
}

View File

@@ -0,0 +1,10 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
@inheritSerialization(ConfigObject)
export class SubmissionFormsModel extends ConfigObject {
@autoserialize
fields: any[];
}

View File

@@ -0,0 +1,22 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model';
@inheritSerialization(ConfigObject)
export class SubmissionSectionModel extends ConfigObject {
@autoserialize
header: string;
@autoserialize
mandatory: boolean;
@autoserialize
sectionType: string;
@autoserialize
visibility: {
main: any,
other: any
}
}

View File

@@ -0,0 +1,14 @@
/**
* TODO replace with actual string enum after upgrade to TypeScript 2.4:
* https://github.com/Microsoft/TypeScript/pull/15486
*/
import { ResourceType } from '../resource-type';
export enum ConfigType {
SubmissionDefinitions = 'submissiondefinitions',
SubmissionDefinition = 'submissiondefinition',
SubmissionForm = 'submissionform',
SubmissionForms = 'submissionforms',
SubmissionSections = 'submissionsections',
SubmissionSection = 'submissionsection'
}

View File

@@ -0,0 +1,21 @@
import { autoserialize, autoserializeAs } from 'cerialize';
export abstract class ConfigObject {
@autoserialize
public name: string;
@autoserialize
public type: string;
@autoserialize
public _links: {
[name: string]: string
}
/**
* The link to the rest endpoint where this config object can be found
*/
@autoserialize
self: string;
}