diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 4e3939b425..06fc26aa67 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -1,6 +1,7 @@ import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { ConfigObject } from '../shared/config/config.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -51,4 +52,14 @@ export class ErrorResponse extends RestResponse { 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 */ diff --git a/src/app/core/config/config-data.ts b/src/app/core/config/config-data.ts new file mode 100644 index 0000000000..efcdb7eed4 --- /dev/null +++ b/src/app/core/config/config-data.ts @@ -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[] + ) { + } +} diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts new file mode 100644 index 0000000000..c0d02be82a --- /dev/null +++ b/src/app/core/config/config.service.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts new file mode 100644 index 0000000000..55c4055ed7 --- /dev/null +++ b/src/app/core/config/config.service.ts @@ -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 { + 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 { + 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 { + const request = new ConfigRequest(href); + this.requestService.configure(request); + + return this.getConfig(request); + } + + public getConfigByName(name: string): Observable { + 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 { + 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(); + } + +} diff --git a/src/app/core/config/submission-definitions-config.service.ts b/src/app/core/config/submission-definitions-config.service.ts new file mode 100644 index 0000000000..4857569236 --- /dev/null +++ b/src/app/core/config/submission-definitions-config.service.ts @@ -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(); + } + +} diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config.service.ts new file mode 100644 index 0000000000..5e992146ee --- /dev/null +++ b/src/app/core/config/submission-forms-config.service.ts @@ -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(); + } + +} diff --git a/src/app/core/config/submission-sections-config.service.ts b/src/app/core/config/submission-sections-config.service.ts new file mode 100644 index 0000000000..96a8557e9c --- /dev/null +++ b/src/app/core/config/submission-sections-config.service.ts @@ -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(); + } + +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 39570f9aed..f4c7e2bbcc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -32,7 +32,11 @@ import { ServerResponseService } from '../shared/server-response.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; +import { ConfigResponseParsingService } from './data/config-response-parsing.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 = [ CommonModule, @@ -66,7 +70,11 @@ const PROVIDERS = [ ServerResponseService, BrowseResponseParsingService, BrowseService, + ConfigResponseParsingService, RouteService, + SubmissionDefinitionsConfigService, + SubmissionFormsConfigService, + SubmissionSectionsConfigService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts new file mode 100644 index 0000000000..d8a4221420 --- /dev/null +++ b/src/app/core/data/base-response-parsing.service.ts @@ -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 { + [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(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, ...this.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] = this.flattenSingleKeyObject(obj); + } else { + result[property] = this.deserializeAndCache(data[property], requestHref); + } + }); + } + return result; + } + } + } + + protected deserializeAndCache(obj, requestHref: string): ObjectDomain[] { + if (Array.isArray(obj)) { + let result = []; + obj.forEach((o) => result = [...result, ...this.deserializeAndCache(o, requestHref)]); + return result; + } + + const type: ObjectType = obj.type; + if (hasValue(type)) { + const normObjConstructor = this.objectFactory.getConstructor(type) as GenericConstructor; + + if (hasValue(normObjConstructor)) { + const serializer = new DSpaceRESTv2Serializer(normObjConstructor); + + let processed; + if (isNotEmpty(obj._embedded)) { + processed = this.process(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]]; + } +} diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index be7949826f..0d78a9fa8d 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -66,7 +66,7 @@ describe('ComColDataService', () => { return jasmine.createSpyObj('requestService', ['configure']); } - function initMockResponceCacheService(isSuccessful: boolean): ResponseCacheService { + function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { return jasmine.createSpyObj('responseCache', { get: cold('c-', { c: { response: { isSuccessful } } @@ -107,7 +107,7 @@ describe('ComColDataService', () => { cds = initMockCommunityDataService(); requestService = initMockRequestService(); objectCache = initMockObjectCacheService(); - responseCache = initMockResponceCacheService(true); + responseCache = initMockResponseCacheService(true); service = initTestService(); const expected = new FindByIDRequest(communityEndpoint, scopeID); @@ -123,7 +123,7 @@ describe('ComColDataService', () => { cds = initMockCommunityDataService(); requestService = initMockRequestService(); objectCache = initMockObjectCacheService(); - responseCache = initMockResponceCacheService(true); + responseCache = initMockResponseCacheService(true); service = initTestService(); }); @@ -146,7 +146,7 @@ describe('ComColDataService', () => { cds = initMockCommunityDataService(); requestService = initMockRequestService(); objectCache = initMockObjectCacheService(); - responseCache = initMockResponceCacheService(false); + responseCache = initMockResponseCacheService(false); service = initTestService(); }); @@ -163,12 +163,12 @@ describe('ComColDataService', () => { cds = initMockCommunityDataService(); requestService = initMockRequestService(); objectCache = initMockObjectCacheService(); - responseCache = initMockResponceCacheService(true); + responseCache = initMockResponseCacheService(true); service = initTestService(); }); 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 expected = cold('--f-', { f: serviceEndpoint }); diff --git a/src/app/core/data/config-response-parsing.service.spec.ts b/src/app/core/data/config-response-parsing.service.spec.ts new file mode 100644 index 0000000000..46e6d61f8f --- /dev/null +++ b/src/app/core/data/config-response-parsing.service.spec.ts @@ -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; + 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); + }); + + }); +}); diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/data/config-response-parsing.service.ts new file mode 100644 index 0000000000..69be4bbc02 --- /dev/null +++ b/src/app/core/data/config-response-parsing.service.ts @@ -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(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} + ) + ); + } + } + +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index b7929498f1..11590d0431 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -1,149 +1,34 @@ -import { ObjectCacheService } from '../cache/object-cache.service'; import { Inject, Injectable } from '@angular/core'; + +import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../../config'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { ResourceType } from '../shared/resource-type'; 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 { RestResponse, DSOSuccessResponse } from '../cache/response-cache.models'; import { RestRequest } from './request.models'; -import { PageInfo } from '../shared/page-info.model'; + import { ResponseParsingService } from './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[] -} +import { BaseResponseParsingService } from './base-response-parsing.service'; @Injectable() -export class DSOResponseParsingService implements ResponseParsingService { +export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = true; + constructor( - @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, - private objectCache: ObjectCacheService, - ) { + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const processRequestDTO = this.process(data.payload, request.href); - const selfLinks = flattenSingleKeyObject(processRequestDTO).map((no) => no.self); + const processRequestDTO = this.process(data.payload, request.href); + const selfLinks = this.flattenSingleKeyObject(processRequestDTO).map((no) => no.self); 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 */ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index ab3e38d9cd..a80eccfaa8 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -6,6 +6,7 @@ import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RootResponseParsingService } from './root-response-parsing.service'; import { BrowseResponseParsingService } from './browse-response-parsing.service'; +import { ConfigResponseParsingService } from './config-response-parsing.service'; /* tslint:disable:max-classes-per-file */ export class RestRequest { @@ -64,6 +65,16 @@ export class BrowseEndpointRequest extends RestRequest { } } +export class ConfigRequest extends RestRequest { + constructor(href: string) { + super(href); + } + + getResponseParser(): GenericConstructor { + return ConfigResponseParsingService; + } +} + export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 3036c7be21..0eee771a52 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -26,7 +26,6 @@ export function requestStateSelector(): MemoizedSelector { + 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; + } + } + } +} diff --git a/src/app/core/shared/config/config-submission-definitions.model.ts b/src/app/core/shared/config/config-submission-definitions.model.ts new file mode 100644 index 0000000000..8249d2b118 --- /dev/null +++ b/src/app/core/shared/config/config-submission-definitions.model.ts @@ -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[]; + +} diff --git a/src/app/core/shared/config/config-submission-forms.model.ts b/src/app/core/shared/config/config-submission-forms.model.ts new file mode 100644 index 0000000000..0b094091a7 --- /dev/null +++ b/src/app/core/shared/config/config-submission-forms.model.ts @@ -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[]; + +} diff --git a/src/app/core/shared/config/config-submission-section.model.ts b/src/app/core/shared/config/config-submission-section.model.ts new file mode 100644 index 0000000000..0eb9daaeab --- /dev/null +++ b/src/app/core/shared/config/config-submission-section.model.ts @@ -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 + } + +} diff --git a/src/app/core/shared/config/config-type.ts b/src/app/core/shared/config/config-type.ts new file mode 100644 index 0000000000..ab0a18e516 --- /dev/null +++ b/src/app/core/shared/config/config-type.ts @@ -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' +} diff --git a/src/app/core/shared/config/config.model.ts b/src/app/core/shared/config/config.model.ts new file mode 100644 index 0000000000..8d86f317e1 --- /dev/null +++ b/src/app/core/shared/config/config.model.ts @@ -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; +}