Merge branch 'master' into embedded-objects-fixes

Conflicts:
	src/app/core/cache/builders/remote-data-build.service.ts
This commit is contained in:
Lotte Hofstede
2018-05-28 10:43:47 +02:00
23 changed files with 972 additions and 170 deletions

View File

@@ -1,5 +1,60 @@
# Configuration # Configuration
Default configuration file is located in `config/` folder. All configuration options should be listed in the default configuration file `config/environment.default.js`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change:
- Create a new `environment.dev.js` file in `config/` for `devel` environment;
- Create a new `environment.prod.js` file in `config/` for `production` environment;
Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below.
## Nodejs server
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:3000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):
```
module.exports = {
// Angular Universal server settings.
ui: {
ssl: false,
host: 'localhost',
port: 3000,
nameSpace: '/'
}
};
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
```
DSPACE_SSL=true
DSPACE_HOST=localhost
DSPACE_PORT=3000
DSPACE_NAMESPACE=/
```
## DSpace's REST endpoint
dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options:
```
module.exports = {
// The REST API server settings.
rest: {
ssl: true,
host: 'dspace7.4science.it',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/dspace-spring-rest/api'
}
};
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
```
DSPACE_REST_SSL=true
DSPACE_REST_HOST=localhost
DSPACE_REST_PORT=3000
DSPACE_REST_NAMESPACE=/
```
## Supporting analytics services other than Google Analytics ## Supporting analytics services other than Google Analytics
This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics. This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics.

View File

@@ -23,6 +23,7 @@ import { RequestService } from '../../core/data/request.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { configureRequest } from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
@@ -78,7 +79,7 @@ export class SearchService implements OnDestroy {
} }
}); });
}), }),
tap((request: RestRequest) => this.requestService.configure(request)), configureRequest(this.requestService)
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
@@ -153,7 +154,7 @@ export class SearchService implements OnDestroy {
} }
}); });
}), }),
tap((request: RestRequest) => this.requestService.configure(request)), configureRequest(this.requestService)
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(
@@ -188,7 +189,7 @@ export class SearchService implements OnDestroy {
} }
}); });
}), }),
tap((request: RestRequest) => this.requestService.configure(request)), configureRequest(this.requestService)
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(

View File

@@ -1,24 +1,28 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/Rx';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { BrowseService } from './browse.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { hot, cold, getTestScheduler } from 'jasmine-marbles';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseEndpointRequest } from '../data/request.models';
import { TestScheduler } from 'rxjs/Rx';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { BrowseEndpointRequest, BrowseEntriesRequest } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseService } from './browse.service';
describe('BrowseService', () => { describe('BrowseService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: BrowseService; let service: BrowseService;
let responseCache: ResponseCacheService; let responseCache: ResponseCacheService;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService;
const browsesEndpointURL = 'https://rest.api/browses'; const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const browseDefinitions = [ const browseDefinitions = [
Object.assign(new BrowseDefinition(), { Object.assign(new BrowseDefinition(), {
id: 'date',
metadataBrowse: false, metadataBrowse: false,
sortOptions: [ sortOptions: [
{ {
@@ -45,6 +49,7 @@ describe('BrowseService', () => {
} }
}), }),
Object.assign(new BrowseDefinition(), { Object.assign(new BrowseDefinition(), {
id: 'author',
metadataBrowse: true, metadataBrowse: true,
sortOptions: [ sortOptions: [
{ {
@@ -80,7 +85,7 @@ describe('BrowseService', () => {
b: { b: {
response: { response: {
isSuccessful, isSuccessful,
browseDefinitions, payload: browseDefinitions,
} }
} }
})); }));
@@ -91,7 +96,8 @@ describe('BrowseService', () => {
return new BrowseService( return new BrowseService(
responseCache, responseCache,
requestService, requestService,
halService halService,
rdbService
); );
} }
@@ -99,15 +105,99 @@ describe('BrowseService', () => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
}); });
describe('getBrowseURLFor', () => { describe('getBrowseDefinitions', () => {
describe('if getEndpoint fires', () => {
beforeEach(() => { beforeEach(() => {
responseCache = initMockResponseCacheService(true); responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService(); requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(halService, 'getEndpoint').and spyOn(halService, 'getEndpoint').and
.returnValue(hot('--a-', { a: browsesEndpointURL })); .returnValue(hot('--a-', { a: browsesEndpointURL }));
spyOn(rdbService, 'toRemoteDataObservable').and.callThrough();
});
it('should configure a new BrowseEndpointRequest', () => {
const expected = new BrowseEndpointRequest(requestService.generateRequestId(), browsesEndpointURL);
scheduler.schedule(() => service.getBrowseDefinitions().subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
service.getBrowseDefinitions();
expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
});
it('should return a RemoteData object containing the correct BrowseDefinition[]', () => {
const expected = cold('--a-', { a: {
payload: browseDefinitions
}});
expect(service.getBrowseDefinitions()).toBeObservable(expected);
});
});
describe('getBrowseEntriesFor', () => {
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and
.returnValue(hot('--a-', { a: {
payload: browseDefinitions
}}));
spyOn(rdbService, 'toRemoteDataObservable').and.callThrough();
});
describe('when called with a valid browse definition id', () => {
it('should configure a new BrowseEntriesRequest', () => {
const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries);
scheduler.schedule(() => service.getBrowseEntriesFor(browseDefinitions[1].id).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
service.getBrowseEntriesFor(browseDefinitions[1].id);
expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
});
});
describe('when called with an invalid browse definition id', () => {
it('should throw an Error', () => {
const definitionID = 'invalidID';
const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`))
expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected);
});
});
});
describe('getBrowseURLFor', () => {
describe('if getBrowseDefinitions fires', () => {
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and
.returnValue(hot('--a-', { a: {
payload: browseDefinitions
}}));
}); });
it('should return the URL for the given metadatumKey and linkPath', () => { it('should return the URL for the given metadatumKey and linkPath', () => {
@@ -152,26 +242,15 @@ describe('BrowseService', () => {
expect(result).toBeObservable(expected); expect(result).toBeObservable(expected);
}); });
it('should configure a new BrowseEndpointRequest', () => {
const metadatumKey = 'dc.date.issued';
const linkPath = 'items';
const expected = new BrowseEndpointRequest(requestService.generateRequestId(), browsesEndpointURL);
scheduler.schedule(() => service.getBrowseURLFor(metadatumKey, linkPath).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
}); });
}); describe('if getBrowseDefinitions doesn\'t fire', () => {
describe('if getEndpoint doesn\'t fire', () => {
it('should return undefined', () => { it('should return undefined', () => {
responseCache = initMockResponseCacheService(true); responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService(); requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(halService, 'getEndpoint').and spyOn(service, 'getBrowseDefinitions').and
.returnValue(hot('----')); .returnValue(hot('----'));
const metadatumKey = 'dc.date.issued'; const metadatumKey = 'dc.date.issued';
@@ -182,22 +261,5 @@ describe('BrowseService', () => {
expect(result).toBeObservable(expected); expect(result).toBeObservable(expected);
}); });
}); });
describe('if the browses endpoint can\'t be retrieved', () => {
it('should throw an error', () => {
responseCache = initMockResponseCacheService(false);
requestService = getMockRequestService();
service = initTestService();
spyOn(halService, 'getEndpoint').and
.returnValue(hot('--a-', { a: browsesEndpointURL }));
const metadatumKey = 'dc.date.issued';
const linkPath = 'items';
const result = service.getBrowseURLFor(metadatumKey, linkPath);
const expected = cold('c-#-', { c: undefined }, new Error(`Couldn't retrieve the browses endpoint`));
expect(result).toBeObservable(expected);
});
});
}); });
}); });

View File

@@ -1,15 +1,34 @@
import { Inject, Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { GLOBAL_CONFIG } from '../../../config'; import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import { GlobalConfig } from '../../../config/global-config.interface'; import {
import { isEmpty, isNotEmpty } from '../../shared/empty.util'; ensureArrayHasValue,
import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; hasValueOperator,
isEmpty,
isNotEmpty,
isNotEmptyOperator
} from '../../shared/empty.util';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortOptions } from '../cache/models/sort-options.model';
import { GenericSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service'; import { ResponseCacheService } from '../cache/response-cache.service';
import { BrowseEndpointRequest, RestRequest } from '../data/request.models'; import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import { BrowseEndpointRequest, BrowseEntriesRequest, RestRequest } from '../data/request.models';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseEntry } from '../shared/browse-entry.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
filterSuccessfulResponses,
getRemoteDataPayload,
getRequestFromSelflink,
getResponseFromSelflink
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
@Injectable() @Injectable()
export class BrowseService { export class BrowseService {
@@ -31,42 +50,106 @@ export class BrowseService {
constructor( constructor(
protected responseCache: ResponseCacheService, protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService) { protected halService: HALEndpointService,
private rdb: RemoteDataBuildService,
) {
}
getBrowseDefinitions(): Observable<RemoteData<BrowseDefinition[]>> {
const request$ = this.halService.getEndpoint(this.linkPath).pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const payload$ = responseCache$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseDefinition[]>) => response.payload),
ensureArrayHasValue(),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
}
getBrowseEntriesFor(definitionID: string, options: {
pagination?: PaginationComponentOptions;
sort?: SortOptions;
} = {}): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
const request$ = this.getBrowseDefinitions().pipe(
getRemoteDataPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true)
),
map((def: BrowseDefinition) => {
if (isNotEmpty(def)) {
return def._links;
} else {
throw new Error(`No metadata browse definition could be found for id '${definitionID}'`);
}
}),
hasValueOperator(),
map((_links: any) => _links.entries),
hasValueOperator(),
map((href: string) => {
// TODO nearly identical to PaginatedSearchOptions => refactor
const args = [];
if (isNotEmpty(options.sort)) {
args.push(`sort=${options.sort.field},${options.sort.direction}`);
}
if (isNotEmpty(options.pagination)) {
args.push(`page=${options.pagination.currentPage - 1}`);
args.push(`size=${options.pagination.pageSize}`);
}
if (isNotEmpty(args)) {
href = new URLCombiner(href, `?${args.join('&')}`).toString();
}
return href;
}),
map((endpointURL: string) => new BrowseEntriesRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const payload$ = responseCache$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
} }
getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> { getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> {
const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey);
return this.halService.getEndpoint(this.linkPath) return this.getBrowseDefinitions().pipe(
.filter((href: string) => isNotEmpty(href)) getRemoteDataPayload(),
.distinctUntilChanged() map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL))
.do((request: RestRequest) => this.requestService.configure(request))
.flatMap((request: RestRequest) => {
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 browses endpoint`))),
successResponse
.filter((response: BrowseSuccessResponse) => isNotEmpty(response.browseDefinitions))
.map((response: BrowseSuccessResponse) => response.browseDefinitions)
.map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => { .find((def: BrowseDefinition) => {
const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0);
return isNotEmpty(matchingKeys); return isNotEmpty(matchingKeys);
}) })
).map((def: BrowseDefinition) => { ),
map((def: BrowseDefinition) => {
if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) {
throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`); throw new Error(`A browse endpoint for ${linkPath} on ${metadatumKey} isn't configured`);
} else { } else {
return def._links[linkPath]; return def._links[linkPath];
} }
}) }),
startWith(undefined),
distinctUntilChanged()
); );
}).startWith(undefined)
.distinctUntilChanged();
} }
} }

View File

@@ -1,10 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { map, tap } from 'rxjs/operators'; import { distinctUntilChanged, flatMap, map, startWith } from 'rxjs/operators';
import { NormalizedSearchResult } from '../../../+search-page/normalized-search-result.model'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { SearchResult } from '../../../+search-page/search-result.model';
import { SearchQueryResponse } from '../../../+search-page/search-service/search-query-response.model';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { PaginatedList } from '../../data/paginated-list'; import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data'; import { RemoteData } from '../../data/remote-data';
import { RemoteDataError } from '../../data/remote-data-error'; import { RemoteDataError } from '../../data/remote-data-error';
@@ -12,13 +9,19 @@ import { GetRequest } from '../../data/request.models';
import { RequestEntry } from '../../data/request.reducer'; import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service'; import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service'; import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models'; import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models';
import { ResponseCacheEntry } from '../response-cache.reducer'; import { ResponseCacheEntry } from '../response-cache.reducer';
import { ResponseCacheService } from '../response-cache.service'; import { ResponseCacheService } from '../response-cache.service';
import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators';
import { NormalizedObject } from '../models/normalized-object.model';
import { PageInfo } from '../../shared/page-info.model'; import { PageInfo } from '../../shared/page-info.model';
import {
getRequestFromSelflink,
getResourceLinksFromResponse,
getResponseFromSelflink,
filterSuccessfulResponses
} from '../../shared/operators';
@Injectable() @Injectable()
export class RemoteDataBuildService { export class RemoteDataBuildService {
@@ -27,43 +30,42 @@ export class RemoteDataBuildService {
protected requestService: RequestService) { protected requestService: RequestService) {
} }
buildSingle<TNormalized extends NormalizedObject, TDomain>(hrefObs: string | Observable<string>): Observable<RemoteData<TDomain>> { buildSingle<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain>> {
if (typeof hrefObs === 'string') { if (typeof href$ === 'string') {
hrefObs = Observable.of(hrefObs); href$ = Observable.of(href$);
} }
const requestHrefObs = hrefObs.flatMap((href: string) => const requestHref$ = href$.pipe(flatMap((href: string) =>
this.objectCache.getRequestHrefBySelfLink(href)); this.objectCache.getRequestHrefBySelfLink(href)));
const requestEntryObs = Observable.race( const requestEntry$ = Observable.race(
hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) href$.pipe(getRequestFromSelflink(this.requestService)),
.filter((entry) => hasValue(entry)), requestHref$.pipe(getRequestFromSelflink(this.requestService))
requestHrefObs.flatMap((requestHref) =>
this.requestService.getByHref(requestHref)).filter((entry) => hasValue(entry))
); );
const responseCacheObs = Observable.race( const responseCache$ = Observable.race(
hrefObs.flatMap((href: string) => this.responseCache.get(href)) href$.pipe(getResponseFromSelflink(this.responseCache)),
.filter((entry) => hasValue(entry)), requestHref$.pipe(getResponseFromSelflink(this.responseCache))
requestHrefObs.flatMap((requestHref) => this.responseCache.get(requestHref)).filter((entry) => hasValue(entry))
); );
// always use self link if that is cached, only if it isn't, get it via the response. // always use self link if that is cached, only if it isn't, get it via the response.
const payloadObs = const payload$ =
Observable.combineLatest( Observable.combineLatest(
hrefObs.flatMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)) href$.pipe(
.startWith(undefined), flatMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
responseCacheObs startWith(undefined)
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) ),
.map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) responseCache$.pipe(
.flatMap((resourceSelfLinks: string[]) => { getResourceLinksFromResponse(),
flatMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) { if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]); return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
} else { } else {
return Observable.of(undefined); return Observable.of(undefined);
} }
}) }),
.distinctUntilChanged() distinctUntilChanged(),
.startWith(undefined), startWith(undefined)
),
(fromSelfLink, fromResponse) => { (fromSelfLink, fromResponse) => {
if (hasValue(fromSelfLink)) { if (hasValue(fromSelfLink)) {
return fromSelfLink; return fromSelfLink;
@@ -71,18 +73,19 @@ export class RemoteDataBuildService {
return fromResponse; return fromResponse;
} }
} }
).filter((normalized) => hasValue(normalized)) ).pipe(
.map((normalized: TNormalized) => { hasValueOperator(),
map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized); return this.build<TNormalized, TDomain>(normalized);
}) }),
.startWith(undefined) startWith(undefined),
.distinctUntilChanged(); distinctUntilChanged()
return this.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); );
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
} }
toRemoteDataObservable<T>(requestEntryObs: Observable<RequestEntry>, responseCacheObs: Observable<ResponseCacheEntry>, payloadObs: Observable<T>) { toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, responseCache$: Observable<ResponseCacheEntry>, payload$: Observable<T>) {
return Observable.combineLatest(requestEntry$, responseCache$.startWith(undefined), payload$,
return Observable.combineLatest(requestEntryObs, responseCacheObs.startWith(undefined), payloadObs,
(reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => { (reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => {
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
@@ -105,33 +108,31 @@ export class RemoteDataBuildService {
}); });
} }
buildList<TNormalized extends NormalizedObject, TDomain>(hrefObs: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> { buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
if (typeof hrefObs === 'string') { if (typeof href$ === 'string') {
hrefObs = Observable.of(hrefObs); href$ = Observable.of(href$);
} }
const requestEntryObs = hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
.filter((entry) => hasValue(entry)); const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const responseCacheObs = hrefObs.flatMap((href: string) => this.responseCache.get(href))
.filter((entry) => hasValue(entry));
const tDomainListObs = responseCacheObs const tDomainList$ = responseCache$.pipe(
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) getResourceLinksFromResponse(),
.map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) flatMap((resourceUUIDs: string[]) => {
.flatMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs) return this.objectCache.getList(resourceUUIDs)
.map((normList: TNormalized[]) => { .map((normList: TNormalized[]) => {
return normList.map((normalized: TNormalized) => { return normList.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized); return this.build<TNormalized, TDomain>(normalized);
}); });
}); });
}) }),
.startWith([]) startWith([]),
.distinctUntilChanged(); distinctUntilChanged()
);
const pageInfoObs = responseCacheObs const pageInfo$ = responseCache$.pipe(
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) filterSuccessfulResponses(),
.map((entry: ResponseCacheEntry) => { map((entry: ResponseCacheEntry) => {
if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo;
if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
@@ -140,13 +141,14 @@ export class RemoteDataBuildService {
return resPageInfo; return resPageInfo;
} }
} }
}); })
);
const payloadObs = Observable.combineLatest(tDomainListObs, pageInfoObs, (tDomainList, pageInfo) => { const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => {
return new PaginatedList(pageInfo, tDomainList); return new PaginatedList(pageInfo, tDomainList);
}); });
return this.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
} }
build<TNormalized, TDomain>(normalized: TNormalized): TDomain { build<TNormalized, TDomain>(normalized: TNormalized): TDomain {

View File

@@ -1,5 +1,6 @@
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
import { RequestError } from '../data/request.models'; import { RequestError } from '../data/request.models';
import { BrowseEntry } from '../shared/browse-entry.model';
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'; import { ConfigObject } from '../shared/config/config.model';
@@ -78,10 +79,11 @@ export class EndpointMapSuccessResponse extends RestResponse {
} }
} }
export class BrowseSuccessResponse extends RestResponse { export class GenericSuccessResponse<T> extends RestResponse {
constructor( constructor(
public browseDefinitions: BrowseDefinition[], public payload: T,
public statusCode: string public statusCode: string,
public pageInfo?: PageInfo
) { ) {
super(true, statusCode); super(true, statusCode);
} }

View File

@@ -15,6 +15,7 @@ import { coreReducers } from './core.reducers';
import { isNotEmpty } from '../shared/empty.util'; import { isNotEmpty } from '../shared/empty.util';
import { ApiService } from '../shared/api.service'; import { ApiService } from '../shared/api.service';
import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service';
import { CollectionDataService } from './data/collection-data.service'; import { CollectionDataService } from './data/collection-data.service';
import { CommunityDataService } from './data/community-data.service'; import { CommunityDataService } from './data/community-data.service';
import { DebugResponseParsingService } from './data/debug-response-parsing.service'; import { DebugResponseParsingService } from './data/debug-response-parsing.service';
@@ -83,6 +84,7 @@ const PROVIDERS = [
SearchResponseParsingService, SearchResponseParsingService,
ServerResponseService, ServerResponseService,
BrowseResponseParsingService, BrowseResponseParsingService,
BrowseEntriesResponseParsingService,
BrowseService, BrowseService,
ConfigResponseParsingService, ConfigResponseParsingService,
RouteService, RouteService,

View File

@@ -0,0 +1,146 @@
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.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';
describe('BrowseEntriesResponseParsingService', () => {
let service: BrowseEntriesResponseParsingService;
beforeEach(() => {
service = new BrowseEntriesResponseParsingService(undefined, getMockObjectCacheService());
});
describe('parse', () => {
const request = new BrowseEntriesRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/discover/browses/author/entries');
const validResponse = {
payload: {
_embedded: {
browseEntries: [
{
authority: null,
value: 'Arulmozhiyal, Ramaswamy',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Arulmozhiyal, Ramaswamy'
}
}
},
{
authority: null,
value: 'Bastida-Jumilla, Ma Consuelo',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Bastida-Jumilla, Ma Consuelo'
}
}
},
{
authority: null,
value: 'Cao, Binggang',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Cao, Binggang'
}
}
},
{
authority: null,
value: 'Castelli, Mauro',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Castelli, Mauro'
}
}
},
{
authority: null,
value: 'Cat, Lily',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Cat, Lily'
}
}
}
]
},
_links: {
first: {
href: 'https://rest.api/discover/browses/author/entries?page=0&size=5'
},
self: {
href: 'https://rest.api/discover/browses/author/entries'
},
next: {
href: 'https://rest.api/discover/browses/author/entries?page=1&size=5'
},
last: {
href: 'https://rest.api/discover/browses/author/entries?page=9&size=5'
}
},
page: {
size: 5,
totalElements: 50,
totalPages: 10,
number: 0
}
},
statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponseNotAList = {
payload: {
authority: null,
value: 'Arulmozhiyal, Ramaswamy',
valueLang: null,
count: 1,
type: 'browseEntry',
_links: {
self: {
href: 'https://rest.api/discover/browses/author/entries'
},
items: {
href: 'https://rest.api/discover/browses/author/items?filterValue=Arulmozhiyal, Ramaswamy'
}
},
},
statusCode: '200'
} as DSpaceRESTV2Response;
const invalidResponseStatusCode = {
payload: {}, statusCode: '500'
} as DSpaceRESTV2Response;
it('should return a GenericSuccessResponse if data contains a valid browse entries 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,48 @@
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-cache.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';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
@Injectable()
export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = {
getConstructor: () => BrowseEntry
};
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._embedded)
&& Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceRESTv2Serializer(BrowseEntry);
const browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
return new GenericSuccessResponse(browseEntries, 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 { BrowseResponseParsingService } from './browse-response-parsing.service';
import { BrowseEndpointRequest } from './request.models'; import { BrowseEndpointRequest } from './request.models';
import { BrowseSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; import { GenericSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
@@ -12,7 +12,7 @@ describe('BrowseResponseParsingService', () => {
}); });
describe('parse', () => { describe('parse', () => {
const validRequest = new BrowseEndpointRequest('clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses'); const validRequest = new BrowseEndpointRequest('client/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses');
const validResponse = { const validResponse = {
payload: { payload: {
@@ -138,9 +138,9 @@ describe('BrowseResponseParsingService', () => {
}) })
]; ];
it('should return a BrowseSuccessResponse if data contains a valid browse endpoint response', () => { it('should return a GenericSuccessResponse if data contains a valid browse endpoint response', () => {
const response = service.parse(validRequest, validResponse); const response = service.parse(validRequest, validResponse);
expect(response.constructor).toBe(BrowseSuccessResponse); expect(response.constructor).toBe(GenericSuccessResponse);
}); });
it('should return an ErrorResponse if data contains an invalid browse endpoint response', () => { it('should return an ErrorResponse if data contains an invalid browse endpoint response', () => {
@@ -155,9 +155,9 @@ describe('BrowseResponseParsingService', () => {
expect(response.constructor).toBe(ErrorResponse); expect(response.constructor).toBe(ErrorResponse);
}); });
it('should return a BrowseSuccessResponse with the BrowseDefinitions in data', () => { it('should return a GenericSuccessResponse with the BrowseDefinitions in data', () => {
const response = service.parse(validRequest, validResponse); const response = service.parse(validRequest, validResponse);
expect((response as BrowseSuccessResponse).browseDefinitions).toEqual(definitions); expect((response as GenericSuccessResponse<BrowseDefinition[]>).payload).toEqual(definitions);
}); });
}); });

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models'; import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
@@ -15,7 +15,7 @@ export class BrowseResponseParsingService implements ResponseParsingService {
&& Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); const serializer = new DSpaceRESTv2Serializer(BrowseDefinition);
const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
return new BrowseSuccessResponse(browseDefinitions, data.statusCode); return new GenericSuccessResponse(browseDefinitions, data.statusCode);
} else { } else {
return new ErrorResponse( return new ErrorResponse(
Object.assign( Object.assign(

View File

@@ -2,6 +2,7 @@ import { SortOptions } from '../cache/models/sort-options.model';
import { GenericConstructor } from '../shared/generic-constructor'; import { GenericConstructor } from '../shared/generic-constructor';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service';
import { DSOResponseParsingService } from './dso-response-parsing.service'; import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service';
@@ -164,6 +165,12 @@ export class BrowseEndpointRequest extends GetRequest {
} }
} }
export class BrowseEntriesRequest extends GetRequest {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return BrowseEntriesResponseParsingService;
}
}
export class ConfigRequest extends GetRequest { export class ConfigRequest extends GetRequest {
constructor(uuid: string, href: string) { constructor(uuid: string, href: string) {
super(uuid, href); super(uuid, href);

View File

@@ -2,6 +2,9 @@ import { autoserialize, autoserializeAs } from 'cerialize';
import { SortOption } from './sort-option.model'; import { SortOption } from './sort-option.model';
export class BrowseDefinition { export class BrowseDefinition {
@autoserialize
id: string;
@autoserialize @autoserialize
metadataBrowse: boolean; metadataBrowse: boolean;

View File

@@ -0,0 +1,20 @@
import { autoserialize, autoserializeAs } from 'cerialize';
export class BrowseEntry {
@autoserialize
type: string;
@autoserialize
authority: string;
@autoserialize
value: string;
@autoserializeAs('valueLang')
language: string;
@autoserialize
count: number;
}

View File

@@ -0,0 +1,152 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from '../../../../node_modules/rxjs';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { GetRequest, RestRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import {
configureRequest,
filterSuccessfulResponses, getRemoteDataPayload,
getRequestFromSelflink, getResourceLinksFromResponse,
getResponseFromSelflink
} from './operators';
describe('Core Module - RxJS Operators', () => {
let scheduler: TestScheduler;
let requestService: RequestService;
const testSelfLink = 'https://rest.api/';
const testRCEs = {
a: { response: { isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd'] } },
b: { response: { isSuccessful: false, resourceSelfLinks: ['e', 'f'] } },
c: { response: { isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i'] } },
d: { response: { isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n'] } },
e: { response: { isSuccessful: 1, resourceSelfLinks: [] } }
};
beforeEach(() => {
scheduler = getTestScheduler();
});
describe('getRequestFromSelflink', () => {
it('should return the RequestEntry corresponding to the self link in the source', () => {
requestService = getMockRequestService();
const source = hot('a', { a: testSelfLink });
const result = source.pipe(getRequestFromSelflink(requestService));
const expected = cold('a', { a: new RequestEntry()});
expect(result).toBeObservable(expected)
});
it('should use the requestService to fetch the request by its self link', () => {
requestService = getMockRequestService();
const source = hot('a', { a: testSelfLink });
scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe());
scheduler.flush();
expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink)
});
it('shouldn\'t return anything if there is no request matching the self link', () => {
requestService = getMockRequestService(cold('a', { a: undefined }));
const source = hot('a', { a: testSelfLink });
const result = source.pipe(getRequestFromSelflink(requestService));
const expected = cold('-');
expect(result).toBeObservable(expected)
});
});
describe('getResponseFromSelflink', () => {
let responseCacheService: ResponseCacheService;
beforeEach(() => {
scheduler = getTestScheduler();
});
it('should return the ResponseCacheEntry corresponding to the self link in the source', () => {
responseCacheService = getMockResponseCacheService();
const source = hot('a', { a: testSelfLink });
const result = source.pipe(getResponseFromSelflink(responseCacheService));
const expected = cold('a', { a: new ResponseCacheEntry()});
expect(result).toBeObservable(expected)
});
it('should use the responseCacheService to fetch the response by the request\'s link', () => {
responseCacheService = getMockResponseCacheService();
const source = hot('a', { a: testSelfLink });
scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe());
scheduler.flush();
expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink)
});
it('shouldn\'t return anything if there is no response matching the request\'s link', () => {
responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined }));
const source = hot('a', { a: testSelfLink });
const result = source.pipe(getResponseFromSelflink(responseCacheService));
const expected = cold('-');
expect(result).toBeObservable(expected)
});
});
describe('filterSuccessfulResponses', () => {
it('should only return responses for which isSuccessful === true', () => {
const source = hot('abcde', testRCEs);
const result = source.pipe(filterSuccessfulResponses());
const expected = cold('a--d-', testRCEs);
expect(result).toBeObservable(expected)
});
});
describe('getResourceLinksFromResponse', () => {
it('should return the resourceSelfLinks for all successful responses', () => {
const source = hot('abcde', testRCEs);
const result = source.pipe(getResourceLinksFromResponse());
const expected = cold('a--d-', {
a: testRCEs.a.response.resourceSelfLinks,
d: testRCEs.d.response.resourceSelfLinks
});
expect(result).toBeObservable(expected)
});
});
describe('configureRequest', () => {
it('should call requestService.configure with the source request', () => {
requestService = getMockRequestService();
const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink);
const source = hot('a', { a: testRequest });
scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(testRequest)
});
});
describe('getRemoteDataPayload', () => {
it('should return the payload of the source RemoteData', () => {
const testRD = { a: { payload: 'a' } };
const source = hot('a', testRD);
const result = source.pipe(getRemoteDataPayload());
const expected = cold('a', {
a: testRD.a.payload,
});
expect(result).toBeObservable(expected)
});
});
});

View File

@@ -0,0 +1,47 @@
import { Observable } from 'rxjs/Observable';
import { filter, flatMap, map, tap } from 'rxjs/operators';
import { hasValueOperator } from '../../shared/empty.util';
import { DSOSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RemoteData } from '../data/remote-data';
import { RestRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
/**
* This file contains custom RxJS operators that can be used in multiple places
*/
export const getRequestFromSelflink = (requestService: RequestService) =>
(source: Observable<string>): Observable<RequestEntry> =>
source.pipe(
flatMap((href: string) => requestService.getByHref(href)),
hasValueOperator()
);
export const getResponseFromSelflink = (responseCache: ResponseCacheService) =>
(source: Observable<string>): Observable<ResponseCacheEntry> =>
source.pipe(
flatMap((href: string) => responseCache.get(href)),
hasValueOperator()
);
export const filterSuccessfulResponses = () =>
(source: Observable<ResponseCacheEntry>): Observable<ResponseCacheEntry> =>
source.pipe(filter((entry: ResponseCacheEntry) => entry.response.isSuccessful === true));
export const getResourceLinksFromResponse = () =>
(source: Observable<ResponseCacheEntry>): Observable<string[]> =>
source.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks),
);
export const configureRequest = (requestService: RequestService) =>
(source: Observable<RestRequest>): Observable<RestRequest> =>
source.pipe(tap((request: RestRequest) => requestService.configure(request)));
export const getRemoteDataPayload = () =>
<T>(source: Observable<RemoteData<T>>): Observable<T> =>
source.pipe(map((remoteData: RemoteData<T>) => remoteData.payload));

View File

@@ -1,6 +1,16 @@
import { cold, hot } from 'jasmine-marbles';
import { import {
isEmpty, hasNoValue, hasValue, isNotEmpty, isNull, isNotNull, ensureArrayHasValue,
isUndefined, isNotUndefined hasNoValue,
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotEmptyOperator,
isNotNull,
isNotUndefined,
isNull,
isUndefined
} from './empty.util'; } from './empty.util';
describe('Empty Utils', () => { describe('Empty Utils', () => {
@@ -274,6 +284,25 @@ describe('Empty Utils', () => {
}); });
describe('hasValueOperator', () => {
it('should only include items from the source observable for which hasValue is true, and omit all others', () => {
const testData = {
a: null,
b: 'test',
c: true,
d: undefined,
e: 1,
f: {}
};
const source$ = hot('abcdef', testData);
const expected$ = cold('-bc-ef', testData);
const result$ = source$.pipe(hasValueOperator());
expect(result$).toBeObservable(expected$);
});
});
describe('isEmpty', () => { describe('isEmpty', () => {
it('should return true for null', () => { it('should return true for null', () => {
expect(isEmpty(null)).toBe(true); expect(isEmpty(null)).toBe(true);
@@ -393,4 +422,56 @@ describe('Empty Utils', () => {
}); });
}); });
describe('isNotEmptyOperator', () => {
it('should only include items from the source observable for which isNotEmpty is true, and omit all others', () => {
const testData = {
a: null,
b: 'test',
c: true,
d: undefined,
e: 1,
f: {},
g: '',
h: ' '
};
const source$ = hot('abcdefgh', testData);
const expected$ = cold('-bc-e--h', testData);
const result$ = source$.pipe(isNotEmptyOperator());
expect(result$).toBeObservable(expected$);
});
});
describe('ensureArrayHasValue', () => {
it('should let all arrays pass unchanged, and turn everything else in to empty arrays', () => {
const sourceData = {
a: { a: 'b' },
b: ['a', 'b', 'c'],
c: null,
d: [1],
e: undefined,
f: [],
g: () => true,
h: {},
i: ''
};
const expectedData = Object.assign({}, sourceData, {
a: [],
c: [],
e: [],
g: [],
h: [],
i: []
});
const source$ = hot('abcdefghi', sourceData);
const expected$ = cold('abcdefghi', expectedData);
const result$ = source$.pipe(ensureArrayHasValue());
expect(result$).toBeObservable(expected$);
});
});
}); });

View File

@@ -1,3 +1,6 @@
import { Observable } from 'rxjs/Observable';
import { filter, map } from 'rxjs/operators';
/** /**
* Returns true if the passed value is null. * Returns true if the passed value is null.
* isNull(); // false * isNull(); // false
@@ -82,6 +85,14 @@ export function hasValue(obj?: any): boolean {
return isNotUndefined(obj) && isNotNull(obj); return isNotUndefined(obj) && isNotNull(obj);
} }
/**
* Filter items emitted by the source Observable by only emitting those for
* which hasValue is true
*/
export const hasValueOperator = () =>
<T>(source: Observable<T>): Observable<T> =>
source.pipe(filter((obj: T) => hasValue(obj)));
/** /**
* Verifies that a value is `null` or an empty string, empty array, * Verifies that a value is `null` or an empty string, empty array,
* or empty function. * or empty function.
@@ -148,3 +159,21 @@ export function isEmpty(obj?: any): boolean {
export function isNotEmpty(obj?: any): boolean { export function isNotEmpty(obj?: any): boolean {
return !isEmpty(obj); return !isEmpty(obj);
} }
/**
* Filter items emitted by the source Observable by only emitting those for
* which isNotEmpty is true
*/
export const isNotEmptyOperator = () =>
<T>(source: Observable<T>): Observable<T> =>
source.pipe(filter((obj: T) => isNotEmpty(obj)));
/**
* Tests each value emitted by the source Observable,
* let's arrays pass through, turns other values in to
* empty arrays. Used to be able to chain array operators
* on something that may not have a value
*/
export const ensureArrayHasValue = () =>
<T>(source: Observable<T[]>): Observable<T[]> =>
source.pipe(map((arr: T[]): T[] => Array.isArray(arr) ? arr : []));

View File

@@ -0,0 +1,23 @@
import { Observable } from 'rxjs/Observable';
import { map, take } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RemoteData } from '../../core/data/remote-data';
import { RequestEntry } from '../../core/data/request.reducer';
import { hasValue } from '../empty.util';
export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observable<RemoteData<any>>): RemoteDataBuildService {
return {
toRemoteDataObservable: (requestEntry$: Observable<RequestEntry>, responseCache$: Observable<ResponseCacheEntry>, payload$: Observable<any>) => {
if (hasValue(toRemoteDataObservable$)) {
return toRemoteDataObservable$;
} else {
return payload$.pipe(map((payload) => ({
payload
} as RemoteData<any>)))
}
}
} as RemoteDataBuildService;
}

View File

@@ -1,10 +1,11 @@
import { Observable } from 'rxjs/Observable';
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { RequestEntry } from '../../core/data/request.reducer'; import { RequestEntry } from '../../core/data/request.reducer';
export function getMockRequestService(): RequestService { export function getMockRequestService(getByHref$: Observable<RequestEntry> = Observable.of(new RequestEntry())): RequestService {
return jasmine.createSpyObj('requestService', { return jasmine.createSpyObj('requestService', {
configure: () => false, configure: false,
generateRequestId: () => 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', generateRequestId: 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78',
getByHref: (uuid: string) => new RequestEntry() getByHref: getByHref$
}); });
} }

View File

@@ -1,12 +1,16 @@
import { ResponseCacheService } from '../../core/cache/response-cache.service'; import { Observable } from 'rxjs/Observable';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RestResponse } from '../../core/cache/response-cache.models'; import { ResponseCacheService } from '../../core/cache/response-cache.service';
export function getMockResponseCacheService(): ResponseCacheService { export function getMockResponseCacheService(
add$: Observable<ResponseCacheEntry> = Observable.of(new ResponseCacheEntry()),
get$: Observable<ResponseCacheEntry> = Observable.of(new ResponseCacheEntry()),
has: boolean = false
): ResponseCacheService {
return jasmine.createSpyObj('ResponseCacheService', { return jasmine.createSpyObj('ResponseCacheService', {
add: (key: string, response: RestResponse, msToLive: number) => new ResponseCacheEntry(), add: add$,
get: (key: string) => new ResponseCacheEntry(), get: get$,
has: (key: string) => false, has,
}); });
} }

View File

@@ -4,6 +4,7 @@ import { InjectionToken } from '@angular/core';
import { Config } from './config/config.interface'; import { Config } from './config/config.interface';
import { ServerConfig } from './config/server-config.interface'; import { ServerConfig } from './config/server-config.interface';
import { GlobalConfig } from './config/global-config.interface'; import { GlobalConfig } from './config/global-config.interface';
import { hasValue } from './app/shared/empty.util';
const GLOBAL_CONFIG: InjectionToken<GlobalConfig> = new InjectionToken<GlobalConfig>('config'); const GLOBAL_CONFIG: InjectionToken<GlobalConfig> = new InjectionToken<GlobalConfig>('config');
@@ -55,6 +56,41 @@ if (envConfigFile) {
} }
} }
// allow to override a few important options by environment variables
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
const result = { host, nameSpace } as any;
if (hasValue(port)) {
result.port = Number(port);
}
if (hasValue(ssl)) {
result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false;
}
return result;
}
const processEnv = {
ui: createServerConfig(
process.env.DSPACE_HOST,
process.env.DSPACE_PORT,
process.env.DSPACE_NAMESPACE,
process.env.DSPACE_SSL),
rest: createServerConfig(
process.env.DSPACE_REST_HOST,
process.env.DSPACE_REST_PORT,
process.env.DSPACE_REST_NAMESPACE,
process.env.DSPACE_REST_SSL)
} as GlobalConfig;
// merge the environment variables with our configuration.
try {
merge(processEnv)
} catch (e) {
console.warn('Unable to merge environment variable into the configuration')
}
buildBaseUrls(); buildBaseUrls();
// set config for whether running in production // set config for whether running in production

View File

@@ -21,8 +21,6 @@ import { ENV_CONFIG } from './config';
export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) { export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
const app = express(); const app = express();
const port = ENV_CONFIG.ui.port ? ENV_CONFIG.ui.port : 80;
if (ENV_CONFIG.production) { if (ENV_CONFIG.production) {
enableProdMode(); enableProdMode();
app.use(compression()); app.use(compression());
@@ -90,7 +88,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
https.createServer({ https.createServer({
key: keys.serviceKey, key: keys.serviceKey,
cert: keys.certificate cert: keys.certificate
}, app).listen(port, ENV_CONFIG.ui.host, () => { }, app).listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted(); serverStarted();
}); });
} }
@@ -127,7 +125,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
}); });
} }
} else { } else {
app.listen(port, ENV_CONFIG.ui.host, () => { app.listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted(); serverStarted();
}); });
}} }}