refactored requestCacheReducer

This commit is contained in:
Art Lowel
2017-04-12 18:03:02 +02:00
parent 6212f5d114
commit 8e0d2bac9b
47 changed files with 1341 additions and 1309 deletions

View File

@@ -1,14 +1,14 @@
import { combineReducers } from "@ngrx/store"; import { combineReducers } from "@ngrx/store";
import { RequestCacheState, requestCacheReducer } from "./request-cache.reducer"; import { ResponseCacheState, responseCacheReducer } from "./response-cache.reducer";
import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer"; import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer";
export interface CacheState { export interface CacheState {
request: RequestCacheState, response: ResponseCacheState,
object: ObjectCacheState object: ObjectCacheState
} }
export const reducers = { export const reducers = {
request: requestCacheReducer, response: responseCacheReducer,
object: objectCacheReducer object: objectCacheReducer
}; };

View File

@@ -12,6 +12,7 @@ import { CacheEntry } from "./cache-entry";
*/ */
export interface CacheableObject { export interface CacheableObject {
uuid: string; uuid: string;
self?: string;
} }
/** /**

View File

@@ -1,205 +0,0 @@
import { OpaqueToken } from "@angular/core";
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model";
/**
* The list of RequestCacheAction type definitions
*/
export const RequestCacheActionTypes = {
FIND_BY_ID: type('dspace/core/cache/request/FIND_BY_ID'),
FIND_ALL: type('dspace/core/cache/request/FIND_ALL'),
SUCCESS: type('dspace/core/cache/request/SUCCESS'),
ERROR: type('dspace/core/cache/request/ERROR'),
REMOVE: type('dspace/core/cache/request/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS')
};
/**
* An ngrx action to find all objects of a certain type
*/
export class RequestCacheFindAllAction implements Action {
type = RequestCacheActionTypes.FIND_ALL;
payload: {
key: string,
service: OpaqueToken,
scopeID: string,
paginationOptions: PaginationOptions,
sortOptions: SortOptions
};
/**
* Create a new RequestCacheFindAllAction
*
* @param key
* the key under which to cache this request, should be unique
* @param service
* the name of the service that initiated the action
* @param scopeID
* the id of an optional scope object
* @param paginationOptions
* the pagination options
* @param sortOptions
* the sort options
*/
constructor(
key: string,
service: OpaqueToken,
scopeID?: string,
paginationOptions: PaginationOptions = new PaginationOptions(),
sortOptions: SortOptions = new SortOptions()
) {
this.payload = {
key,
service,
scopeID,
paginationOptions,
sortOptions
}
}
}
/**
* An ngrx action to find objects by id
*/
export class RequestCacheFindByIDAction implements Action {
type = RequestCacheActionTypes.FIND_BY_ID;
payload: {
key: string,
service: OpaqueToken,
resourceID: string
};
/**
* Create a new RequestCacheFindByIDAction
*
* @param key
* the key under which to cache this request, should be unique
* @param service
* the name of the service that initiated the action
* @param resourceID
* the ID of the resource to find
*/
constructor(
key: string,
service: OpaqueToken,
resourceID: string
) {
this.payload = {
key,
service,
resourceID
}
}
}
/**
* An ngrx action to indicate a request was returned successful
*/
export class RequestCacheSuccessAction implements Action {
type = RequestCacheActionTypes.SUCCESS;
payload: {
key: string,
resourceUUIDs: Array<string>,
timeAdded: number,
msToLive: number
};
/**
* Create a new RequestCacheSuccessAction
*
* @param key
* the key under which cache this request is cached,
* should be identical to the one used in the corresponding
* find action
* @param resourceUUIDs
* the UUIDs returned from the backend
* @param timeAdded
* the time it was returned
* @param msToLive
* the amount of milliseconds before it should expire
*/
constructor(key: string, resourceUUIDs: Array<string>, timeAdded, msToLive: number) {
this.payload = {
key,
resourceUUIDs,
timeAdded,
msToLive
};
}
}
/**
* An ngrx action to indicate a request failed
*/
export class RequestCacheErrorAction implements Action {
type = RequestCacheActionTypes.ERROR;
payload: {
key: string,
errorMessage: string
};
/**
* Create a new RequestCacheErrorAction
*
* @param key
* the key under which cache this request is cached,
* should be identical to the one used in the corresponding
* find action
* @param errorMessage
* A message describing the reason the request failed
*/
constructor(key: string, errorMessage: string) {
this.payload = {
key,
errorMessage
};
}
}
/**
* An ngrx action to remove a request from the cache
*/
export class RequestCacheRemoveAction implements Action {
type = RequestCacheActionTypes.REMOVE;
payload: string;
/**
* Create a new RequestCacheRemoveAction
* @param key
* The key of the request to remove
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* An ngrx action to reset the timeAdded property of all cached objects
*/
export class ResetRequestCacheTimestampsAction implements Action {
type = RequestCacheActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetObjectCacheTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/**
* A type to encompass all RequestCacheActions
*/
export type RequestCacheAction
= RequestCacheFindAllAction
| RequestCacheFindByIDAction
| RequestCacheSuccessAction
| RequestCacheErrorAction
| RequestCacheRemoveAction
| ResetRequestCacheTimestampsAction;

View File

@@ -1,227 +0,0 @@
import { requestCacheReducer, RequestCacheState } from "./request-cache.reducer";
import {
RequestCacheRemoveAction, RequestCacheFindByIDAction,
RequestCacheFindAllAction, RequestCacheSuccessAction, RequestCacheErrorAction,
ResetRequestCacheTimestampsAction
} from "./request-cache.actions";
import deepFreeze = require("deep-freeze");
import { OpaqueToken } from "@angular/core";
class NullAction extends RequestCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
describe("requestCacheReducer", () => {
const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
const services = [new OpaqueToken('service1'), new OpaqueToken('service2')];
const msToLive = 900000;
const uuids = [
"9e32a2e2-6b91-4236-a361-995ccdc14c60",
"598ce822-c357-46f3-ab70-63724d02d6ad",
"be8325f7-243b-49f4-8a4b-df2b793ff3b5"
];
const resourceID = "9978";
const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
const sortOptions = { "field": "id", "direction": 0 };
const testState = {
[keys[0]]: {
"key": keys[0],
"service": services[0],
"resourceUUIDs": [uuids[0], uuids[1]],
"isLoading": false,
"paginationOptions": paginationOptions,
"sortOptions": sortOptions,
"timeAdded": new Date().getTime(),
"msToLive": msToLive
},
[keys[1]]: {
"key": keys[1],
"service": services[1],
"resourceID": resourceID,
"resourceUUIDs": [uuids[2]],
"isLoading": false,
"timeAdded": new Date().getTime(),
"msToLive": msToLive
}
};
deepFreeze(testState);
const errorState: {} = {
[keys[0]]: {
errorMessage: 'error',
resourceUUIDs: uuids
}
};
deepFreeze(errorState);
it("should return the current state when no valid actions have been made", () => {
const action = new NullAction();
const newState = requestCacheReducer(testState, action);
expect(newState).toEqual(testState);
});
it("should start with an empty cache", () => {
const action = new NullAction();
const initialState = requestCacheReducer(undefined, action);
expect(initialState).toEqual(Object.create(null));
});
describe("FIND_BY_ID", () => {
const action = new RequestCacheFindByIDAction(keys[0], services[0], resourceID);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the request to the cache", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].key).toBe(keys[0]);
expect(newState[keys[0]].service).toEqual(services[0]);
expect(newState[keys[0]].resourceID).toBe(resourceID);
});
it("should set isLoading to true", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].isLoading).toBe(true);
});
it("should remove any previous error message or resourceUUID for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("FIND_ALL", () => {
const action = new RequestCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the request to the cache", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].key).toBe(keys[0]);
expect(newState[keys[0]].service).toEqual(services[0]);
expect(newState[keys[0]].scopeID).toBe(resourceID);
expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions);
expect(newState[keys[0]].sortOptions).toEqual(sortOptions);
});
it("should set isLoading to true", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].isLoading).toBe(true);
});
it("should remove any previous error message or resourceUUIDs for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("SUCCESS", () => {
const successUUIDs = [uuids[0], uuids[2]];
const successTimeAdded = new Date().getTime();
const successMsToLive = 5;
const action = new RequestCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the response to the cached request", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs);
expect(newState[keys[0]].timeAdded).toBe(successTimeAdded);
expect(newState[keys[0]].msToLive).toBe(successMsToLive);
});
it("should set isLoading to false", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].isLoading).toBe(false);
});
it("should remove any previous error message for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("ERROR", () => {
const errorMsg = 'errorMsg';
const action = new RequestCacheErrorAction(keys[0], errorMsg);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should set an error message for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].errorMessage).toBe(errorMsg);
});
it("should set isLoading to false", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].isLoading).toBe(false);
});
});
describe("REMOVE", () => {
it("should perform the action without affecting the previous state", () => {
const action = new RequestCacheRemoveAction(keys[0]);
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should remove the specified request from the cache", () => {
const action = new RequestCacheRemoveAction(keys[0]);
const newState = requestCacheReducer(testState, action);
expect(testState[keys[0]]).not.toBeUndefined();
expect(newState[keys[0]]).toBeUndefined();
});
it("shouldn't do anything when the specified key isn't cached", () => {
const wrongKey = "this isn't cached";
const action = new RequestCacheRemoveAction(wrongKey);
const newState = requestCacheReducer(testState, action);
expect(testState[wrongKey]).toBeUndefined();
expect(newState).toEqual(testState);
});
});
describe("RESET_TIMESTAMPS", () => {
const newTimeStamp = new Date().getTime();
const action = new ResetRequestCacheTimestampsAction(newTimeStamp);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should set the timestamp of all requests in the cache", () => {
const newState = requestCacheReducer(testState, action);
Object.keys(newState).forEach((key) => {
expect(newState[key].timeAdded).toEqual(newTimeStamp);
});
});
});
});

View File

@@ -1,212 +0,0 @@
import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model";
import {
RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction,
RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction,
RequestCacheRemoveAction, ResetRequestCacheTimestampsAction
} from "./request-cache.actions";
import { OpaqueToken } from "@angular/core";
import { CacheEntry } from "./cache-entry";
import { hasValue } from "../../shared/empty.util";
/**
* An entry in the RequestCache
*/
export class RequestCacheEntry implements CacheEntry {
service: OpaqueToken;
key: string;
scopeID: string;
resourceID: string;
resourceUUIDs: Array<String>;
resourceType: String;
isLoading: boolean;
errorMessage: string;
paginationOptions: PaginationOptions;
sortOptions: SortOptions;
timeAdded: number;
msToLive: number;
}
/**
* The RequestCache State
*/
export interface RequestCacheState {
[key: string]: RequestCacheEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
/**
* The RequestCache Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return RequestCacheState
* the new state
*/
export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => {
switch (action.type) {
case RequestCacheActionTypes.FIND_ALL: {
return findAllRequest(state, <RequestCacheFindAllAction> action);
}
case RequestCacheActionTypes.FIND_BY_ID: {
return findByIDRequest(state, <RequestCacheFindByIDAction> action);
}
case RequestCacheActionTypes.SUCCESS: {
return success(state, <RequestCacheSuccessAction> action);
}
case RequestCacheActionTypes.ERROR: {
return error(state, <RequestCacheErrorAction> action);
}
case RequestCacheActionTypes.REMOVE: {
return removeFromCache(state, <RequestCacheRemoveAction> action);
}
case RequestCacheActionTypes.RESET_TIMESTAMPS: {
return resetRequestCacheTimestamps(state, <ResetRequestCacheTimestampsAction>action)
}
default: {
return state;
}
}
};
/**
* Add a FindAll request to the cache
*
* @param state
* the current state
* @param action
* a RequestCacheFindAllAction
* @return RequestCacheState
* the new state, with the request added, or overwritten
*/
function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
service: action.payload.service,
scopeID: action.payload.scopeID,
resourceUUIDs: [],
isLoading: true,
errorMessage: undefined,
paginationOptions: action.payload.paginationOptions,
sortOptions: action.payload.sortOptions
}
});
}
/**
* Add a FindByID request to the cache
*
* @param state
* the current state
* @param action
* a RequestCacheFindByIDAction
* @return RequestCacheState
* the new state, with the request added, or overwritten
*/
function findByIDRequest(state: RequestCacheState, action: RequestCacheFindByIDAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
service: action.payload.service,
resourceID: action.payload.resourceID,
resourceUUIDs: [],
isLoading: true,
errorMessage: undefined,
}
});
}
/**
* Update a cached request with a successful response
*
* @param state
* the current state
* @param action
* a RequestCacheSuccessAction
* @return RequestCacheState
* the new state, with the response added to the request
*/
function success(state: RequestCacheState, action: RequestCacheSuccessAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: Object.assign({}, state[action.payload.key], {
isLoading: false,
resourceUUIDs: action.payload.resourceUUIDs,
errorMessage: undefined,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
})
});
}
/**
* Update a cached request with an error
*
* @param state
* the current state
* @param action
* a RequestCacheSuccessAction
* @return RequestCacheState
* the new state, with the error added to the request
*/
function error(state: RequestCacheState, action: RequestCacheErrorAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: Object.assign({}, state[action.payload.key], {
isLoading: false,
errorMessage: action.payload.errorMessage
})
});
}
/**
* Remove a request from the cache
*
* @param state
* the current state
* @param action
* an RequestCacheRemoveAction
* @return RequestCacheState
* the new state, with the request removed if it existed.
*/
function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAction): RequestCacheState {
if (hasValue(state[action.payload])) {
let newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
}
else {
return state;
}
}
/**
* Set the timeAdded timestamp of every cached request to the specified value
*
* @param state
* the current state
* @param action
* a ResetRequestCacheTimestampsAction
* @return RequestCacheState
* the new state, with all timeAdded timestamps set to the specified value
*/
function resetRequestCacheTimestamps(state: RequestCacheState, action: ResetRequestCacheTimestampsAction): RequestCacheState {
let newState = Object.create(null);
Object.keys(state).forEach(key => {
newState[key] = Object.assign({}, state[key], {
timeAdded: action.payload
});
});
return newState;
}

View File

@@ -1,147 +0,0 @@
import { RequestCacheService } from "./request-cache.service";
import { Store } from "@ngrx/store";
import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer";
import { OpaqueToken } from "@angular/core";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "./request-cache.actions";
import { Observable } from "rxjs";
describe("RequestCacheService", () => {
let service: RequestCacheService;
let store: Store<RequestCacheState>;
const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')];
const resourceID = "9978";
const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
const sortOptions = { "field": "id", "direction": 0 };
const timestamp = new Date().getTime();
const validCacheEntry = (key) => {
return {
key: key,
timeAdded: timestamp,
msToLive: 24 * 60 * 60 * 1000 // a day
}
};
const invalidCacheEntry = (key) => {
return {
key: key,
timeAdded: 0,
msToLive: 0
}
};
beforeEach(() => {
store = new Store<RequestCacheState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new RequestCacheService(store);
spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp });
});
describe("findAll", () => {
beforeEach(() => {
spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
});
describe("if the key isn't cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(false);
});
it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => {
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions))
});
it("should return an observable of the newly cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
describe("if the key is already cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(true);
});
it("shouldn't dispatch anything", () => {
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should return an observable of the existing cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
});
describe("findById", () => {
beforeEach(() => {
spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
});
describe("if the key isn't cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(false);
});
it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => {
service.findById(keys[0], serviceTokens[0], resourceID);
expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindByIDAction(keys[0], serviceTokens[0], resourceID))
});
it("should return an observable of the newly cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
describe("if the key is already cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(true);
});
it("shouldn't dispatch anything", () => {
service.findById(keys[0], serviceTokens[0], resourceID);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should return an observable of the existing cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
});
describe("get", () => {
it("should return an observable of the cached request with the specified key", () => {
spyOn(store, "select").and.callFake((...args:Array<any>) => {
return Observable.of(validCacheEntry(args[args.length - 1]));
});
let testObj: RequestCacheEntry;
service.get(keys[1]).take(1).subscribe(entry => testObj = entry);
expect(testObj.key).toEqual(keys[1]);
});
it("should not return a cached request that has exceeded its time to live", () => {
spyOn(store, "select").and.callFake((...args:Array<any>) => {
return Observable.of(invalidCacheEntry(args[args.length - 1]));
});
let getObsHasFired = false;
const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
});
describe("has", () => {
it("should return true if the request with the supplied key is cached and still valid", () => {
spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(true);
});
it("should return false if the request with the supplied key isn't cached", () => {
spyOn(store, 'select').and.returnValue(Observable.of(undefined));
expect(service.has(keys[1])).toBe(false);
});
it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => {
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(false);
});
});
});

View File

@@ -1,138 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { Store } from "@ngrx/store";
import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer";
import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util";
import {
RequestCacheRemoveAction, RequestCacheFindAllAction,
RequestCacheFindByIDAction
} from "./request-cache.actions";
import { SortOptions } from "../shared/sort-options.model";
import { PaginationOptions } from "../shared/pagination-options.model";
/**
* A service to interact with the request cache
*/
@Injectable()
export class RequestCacheService {
constructor(
private store: Store<RequestCacheState>
) {}
/**
* Start a new findAll request
*
* This will send a new findAll request to the backend,
* and store the request parameters and the fact that
* the request is pending
*
* @param key
* the key should be a unique identifier for the request and its parameters
* @param service
* the service that initiated the request
* @param scopeID
* the id of an optional scope object
* @param paginationOptions
* the pagination options (optional)
* @param sortOptions
* the sort options (optional)
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry for this request
*/
findAll(
key: string,
service: OpaqueToken,
scopeID?: string,
paginationOptions?: PaginationOptions,
sortOptions?: SortOptions
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions));
}
return this.get(key);
}
/**
* Start a new findById request
*
* This will send a new findById request to the backend,
* and store the request parameters and the fact that
* the request is pending
*
* @param key
* the key should be a unique identifier for the request and its parameters
* @param service
* the service that initiated the request
* @param resourceID
* the ID of the resource to find
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry for this request
*/
findById(
key: string,
service: OpaqueToken,
resourceID: string
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindByIDAction(key, service, resourceID));
}
return this.get(key);
}
/**
* Get an observable of the request with the specified key
*
* @param key
* the key of the request to get
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry with the specified key
*/
get(key: string): Observable<RequestCacheEntry> {
return this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
}
/**
* Check whether the request with the specified key is cached
*
* @param key
* the key of the request to check
* @return boolean
* true if the request with the specified key is cached,
* false otherwise
*/
has(key: string): boolean {
let result: boolean;
this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.take(1)
.subscribe(entry => result = this.isValid(entry));
return result;
}
/**
* Check whether a RequestCacheEntry should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isValid(entry: RequestCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
}
else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new RequestCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

@@ -0,0 +1,69 @@
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
import { Response } from "./response-cache.models";
/**
* The list of ResponseCacheAction type definitions
*/
export const ResponseCacheActionTypes = {
ADD: type('dspace/core/cache/request/ADD'),
REMOVE: type('dspace/core/cache/request/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS')
};
export class ResponseCacheAddAction implements Action {
type = ResponseCacheActionTypes.ADD;
payload: {
key: string,
response: Response
timeAdded: number;
msToLive: number;
};
constructor(key: string, response: Response, timeAdded: number, msToLive: number) {
this.payload = { key, response, timeAdded, msToLive };
}
}
/**
* An ngrx action to remove a request from the cache
*/
export class ResponseCacheRemoveAction implements Action {
type = ResponseCacheActionTypes.REMOVE;
payload: string;
/**
* Create a new ResponseCacheRemoveAction
* @param key
* The key of the request to remove
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* An ngrx action to reset the timeAdded property of all cached objects
*/
export class ResetResponseCacheTimestampsAction implements Action {
type = ResponseCacheActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetObjectCacheTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/**
* A type to encompass all ResponseCacheActions
*/
export type ResponseCacheAction
= ResponseCacheAddAction
| ResponseCacheRemoveAction
| ResetResponseCacheTimestampsAction;

View File

@@ -0,0 +1,16 @@
export class Response {
constructor(public isSuccessful: boolean) {}
}
export class SuccessResponse extends Response {
constructor(public resourceUUIDs: Array<String>) {
super(true);
}
}
export class ErrorResponse extends Response {
constructor(public errorMessage: string) {
super(false);
}
}

View File

@@ -0,0 +1,225 @@
import { responseCacheReducer, ResponseCacheState } from "./response-cache.reducer";
import {
ResponseCacheRemoveAction,
ResetResponseCacheTimestampsAction
} from "./response-cache.actions";
import deepFreeze = require("deep-freeze");
class NullAction extends ResponseCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
// describe("responseCacheReducer", () => {
// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
// const services = [new OpaqueToken('service1'), new OpaqueToken('service2')];
// const msToLive = 900000;
// const uuids = [
// "9e32a2e2-6b91-4236-a361-995ccdc14c60",
// "598ce822-c357-46f3-ab70-63724d02d6ad",
// "be8325f7-243b-49f4-8a4b-df2b793ff3b5"
// ];
// const resourceID = "9978";
// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
// const sortOptions = { "field": "id", "direction": 0 };
// const testState = {
// [keys[0]]: {
// "key": keys[0],
// "service": services[0],
// "resourceUUIDs": [uuids[0], uuids[1]],
// "isLoading": false,
// "paginationOptions": paginationOptions,
// "sortOptions": sortOptions,
// "timeAdded": new Date().getTime(),
// "msToLive": msToLive
// },
// [keys[1]]: {
// "key": keys[1],
// "service": services[1],
// "resourceID": resourceID,
// "resourceUUIDs": [uuids[2]],
// "isLoading": false,
// "timeAdded": new Date().getTime(),
// "msToLive": msToLive
// }
// };
// deepFreeze(testState);
// const errorState: {} = {
// [keys[0]]: {
// errorMessage: 'error',
// resourceUUIDs: uuids
// }
// };
// deepFreeze(errorState);
//
//
// it("should return the current state when no valid actions have been made", () => {
// const action = new NullAction();
// const newState = responseCacheReducer(testState, action);
//
// expect(newState).toEqual(testState);
// });
//
// it("should start with an empty cache", () => {
// const action = new NullAction();
// const initialState = responseCacheReducer(undefined, action);
//
// expect(initialState).toEqual(Object.create(null));
// });
//
// describe("FIND_BY_ID", () => {
// const action = new ResponseCacheFindByIDAction(keys[0], services[0], resourceID);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the request to the cache", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].key).toBe(keys[0]);
// expect(newState[keys[0]].service).toEqual(services[0]);
// expect(newState[keys[0]].resourceID).toBe(resourceID);
// });
//
// it("should set responsePending to true", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].responsePending).toBe(true);
// });
//
// it("should remove any previous error message or resourceUUID for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("FIND_ALL", () => {
// const action = new ResponseCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the request to the cache", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].key).toBe(keys[0]);
// expect(newState[keys[0]].service).toEqual(services[0]);
// expect(newState[keys[0]].scopeID).toBe(resourceID);
// expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions);
// expect(newState[keys[0]].sortOptions).toEqual(sortOptions);
// });
//
// it("should set responsePending to true", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].responsePending).toBe(true);
// });
//
// it("should remove any previous error message or resourceUUIDs for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("SUCCESS", () => {
// const successUUIDs = [uuids[0], uuids[2]];
// const successTimeAdded = new Date().getTime();
// const successMsToLive = 5;
// const action = new ResponseCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the response to the cached request", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs);
// expect(newState[keys[0]].timeAdded).toBe(successTimeAdded);
// expect(newState[keys[0]].msToLive).toBe(successMsToLive);
// });
//
// it("should set responsePending to false", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].responsePending).toBe(false);
// });
//
// it("should remove any previous error message for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("ERROR", () => {
// const errorMsg = 'errorMsg';
// const action = new ResponseCacheErrorAction(keys[0], errorMsg);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should set an error message for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].errorMessage).toBe(errorMsg);
// });
//
// it("should set responsePending to false", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].responsePending).toBe(false);
// });
// });
//
// describe("REMOVE", () => {
// it("should perform the action without affecting the previous state", () => {
// const action = new ResponseCacheRemoveAction(keys[0]);
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should remove the specified request from the cache", () => {
// const action = new ResponseCacheRemoveAction(keys[0]);
// const newState = responseCacheReducer(testState, action);
// expect(testState[keys[0]]).not.toBeUndefined();
// expect(newState[keys[0]]).toBeUndefined();
// });
//
// it("shouldn't do anything when the specified key isn't cached", () => {
// const wrongKey = "this isn't cached";
// const action = new ResponseCacheRemoveAction(wrongKey);
// const newState = responseCacheReducer(testState, action);
// expect(testState[wrongKey]).toBeUndefined();
// expect(newState).toEqual(testState);
// });
// });
//
// describe("RESET_TIMESTAMPS", () => {
// const newTimeStamp = new Date().getTime();
// const action = new ResetResponseCacheTimestampsAction(newTimeStamp);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should set the timestamp of all requests in the cache", () => {
// const newState = responseCacheReducer(testState, action);
// Object.keys(newState).forEach((key) => {
// expect(newState[key].timeAdded).toEqual(newTimeStamp);
// });
// });
//
// });
//
//
// });

View File

@@ -0,0 +1,112 @@
import {
ResponseCacheAction, ResponseCacheActionTypes,
ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction,
ResponseCacheAddAction
} from "./response-cache.actions";
import { CacheEntry } from "./cache-entry";
import { hasValue } from "../../shared/empty.util";
import { Response } from "./response-cache.models";
/**
* An entry in the ResponseCache
*/
export class ResponseCacheEntry implements CacheEntry {
key: string;
response: Response;
timeAdded: number;
msToLive: number;
}
/**
* The ResponseCache State
*/
export interface ResponseCacheState {
[key: string]: ResponseCacheEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
/**
* The ResponseCache Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return ResponseCacheState
* the new state
*/
export const responseCacheReducer = (state = initialState, action: ResponseCacheAction): ResponseCacheState => {
switch (action.type) {
case ResponseCacheActionTypes.ADD: {
return addToCache(state, <ResponseCacheAddAction> action);
}
case ResponseCacheActionTypes.REMOVE: {
return removeFromCache(state, <ResponseCacheRemoveAction> action);
}
case ResponseCacheActionTypes.RESET_TIMESTAMPS: {
return resetResponseCacheTimestamps(state, <ResetResponseCacheTimestampsAction>action)
}
default: {
return state;
}
}
};
function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
response: action.payload.response,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
}
});
}
/**
* Remove a request from the cache
*
* @param state
* the current state
* @param action
* an ResponseCacheRemoveAction
* @return ResponseCacheState
* the new state, with the request removed if it existed.
*/
function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState {
if (hasValue(state[action.payload])) {
let newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
}
else {
return state;
}
}
/**
* Set the timeAdded timestamp of every cached request to the specified value
*
* @param state
* the current state
* @param action
* a ResetResponseCacheTimestampsAction
* @return ResponseCacheState
* the new state, with all timeAdded timestamps set to the specified value
*/
function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState {
let newState = Object.create(null);
Object.keys(state).forEach(key => {
newState[key] = Object.assign({}, state[key], {
timeAdded: action.payload
});
});
return newState;
}

View File

@@ -0,0 +1,146 @@
import { ResponseCacheService } from "./response-cache.service";
import { Store } from "@ngrx/store";
import { ResponseCacheState, ResponseCacheEntry } from "./response-cache.reducer";
import { OpaqueToken } from "@angular/core";
import { Observable } from "rxjs";
// describe("ResponseCacheService", () => {
// let service: ResponseCacheService;
// let store: Store<ResponseCacheState>;
//
// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
// const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')];
// const resourceID = "9978";
// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
// const sortOptions = { "field": "id", "direction": 0 };
// const timestamp = new Date().getTime();
// const validCacheEntry = (key) => {
// return {
// key: key,
// timeAdded: timestamp,
// msToLive: 24 * 60 * 60 * 1000 // a day
// }
// };
// const invalidCacheEntry = (key) => {
// return {
// key: key,
// timeAdded: 0,
// msToLive: 0
// }
// };
//
// beforeEach(() => {
// store = new Store<ResponseCacheState>(undefined, undefined, undefined);
// spyOn(store, 'dispatch');
// service = new ResponseCacheService(store);
// spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp });
// });
//
// describe("findAll", () => {
// beforeEach(() => {
// spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
// });
// describe("if the key isn't cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(false);
// });
// it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => {
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions))
// });
// it("should return an observable of the newly cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// describe("if the key is already cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(true);
// });
// it("shouldn't dispatch anything", () => {
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
// expect(store.dispatch).not.toHaveBeenCalled();
// });
// it("should return an observable of the existing cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// });
//
// describe("findById", () => {
// beforeEach(() => {
// spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
// });
// describe("if the key isn't cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(false);
// });
// it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => {
// service.findById(keys[0], serviceTokens[0], resourceID);
// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindByIDAction(keys[0], serviceTokens[0], resourceID))
// });
// it("should return an observable of the newly cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// describe("if the key is already cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(true);
// });
// it("shouldn't dispatch anything", () => {
// service.findById(keys[0], serviceTokens[0], resourceID);
// expect(store.dispatch).not.toHaveBeenCalled();
// });
// it("should return an observable of the existing cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// });
//
// describe("get", () => {
// it("should return an observable of the cached request with the specified key", () => {
// spyOn(store, "select").and.callFake((...args:Array<any>) => {
// return Observable.of(validCacheEntry(args[args.length - 1]));
// });
//
// let testObj: ResponseCacheEntry;
// service.get(keys[1]).take(1).subscribe(entry => testObj = entry);
// expect(testObj.key).toEqual(keys[1]);
// });
//
// it("should not return a cached request that has exceeded its time to live", () => {
// spyOn(store, "select").and.callFake((...args:Array<any>) => {
// return Observable.of(invalidCacheEntry(args[args.length - 1]));
// });
//
// let getObsHasFired = false;
// const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true);
// expect(getObsHasFired).toBe(false);
// subscription.unsubscribe();
// });
// });
//
// describe("has", () => {
// it("should return true if the request with the supplied key is cached and still valid", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1])));
// expect(service.has(keys[1])).toBe(true);
// });
//
// it("should return false if the request with the supplied key isn't cached", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(undefined));
// expect(service.has(keys[1])).toBe(false);
// });
//
// it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1])));
// expect(service.has(keys[1])).toBe(false);
// });
// });
// });

View File

@@ -0,0 +1,87 @@
import { Injectable } from "@angular/core";
import { Store } from "@ngrx/store";
import {
ResponseCacheState, ResponseCacheEntry
} from "./response-cache.reducer";
import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util";
import {
ResponseCacheRemoveAction,
ResponseCacheAddAction
} from "./response-cache.actions";
import { Response } from "./response-cache.models";
/**
* A service to interact with the response cache
*/
@Injectable()
export class ResponseCacheService {
constructor(
private store: Store<ResponseCacheState>
) {}
add(key: string, response: Response, msToLive: number): Observable<ResponseCacheEntry> {
if (!this.has(key)) {
// this.store.dispatch(new ResponseCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions));
this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive));
}
return this.get(key);
}
/**
* Get an observable of the response with the specified key
*
* @param key
* the key of the response to get
* @return Observable<ResponseCacheEntry>
* an observable of the ResponseCacheEntry with the specified key
*/
get(key: string): Observable<ResponseCacheEntry> {
return this.store.select<ResponseCacheEntry>('core', 'cache', 'response', key)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
}
/**
* Check whether the response with the specified key is cached
*
* @param key
* the key of the response to check
* @return boolean
* true if the response with the specified key is cached,
* false otherwise
*/
has(key: string): boolean {
let result: boolean;
this.store.select<ResponseCacheEntry>('core', 'cache', 'response', key)
.take(1)
.subscribe(entry => result = this.isValid(entry));
return result;
}
/**
* Check whether a ResponseCacheEntry should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isValid(entry: ResponseCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
}
else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new ResponseCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

@@ -1,12 +1,11 @@
import { EffectsModule } from "@ngrx/effects"; import { EffectsModule } from "@ngrx/effects";
import { CollectionDataEffects } from "./data-services/collection-data.effects"; import { ObjectCacheEffects } from "./data/object-cache.effects";
import { ItemDataEffects } from "./data-services/item-data.effects"; import { RequestCacheEffects } from "./data/request-cache.effects";
import { ObjectCacheEffects } from "./data-services/object-cache.effects"; import { HrefIndexEffects } from "./index/href-index.effects";
import { RequestCacheEffects } from "./data-services/request-cache.effects"; import { RequestEffects } from "./data/request.effects";
export const coreEffects = [ export const coreEffects = [
EffectsModule.run(CollectionDataEffects), EffectsModule.run(RequestEffects),
EffectsModule.run(ItemDataEffects),
EffectsModule.run(RequestCacheEffects),
EffectsModule.run(ObjectCacheEffects), EffectsModule.run(ObjectCacheEffects),
EffectsModule.run(HrefIndexEffects),
]; ];

View File

@@ -5,9 +5,9 @@ import { isNotEmpty } from "../shared/empty.util";
import { FooterComponent } from "./footer/footer.component"; import { FooterComponent } from "./footer/footer.component";
import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "./cache/object-cache.service"; import { ObjectCacheService } from "./cache/object-cache.service";
import { RequestCacheService } from "./cache/request-cache.service"; import { ResponseCacheService } from "./cache/response-cache.service";
import { CollectionDataService } from "./data-services/collection-data.service"; import { CollectionDataService } from "./data/collection-data.service";
import { ItemDataService } from "./data-services/item-data.service"; import { ItemDataService } from "./data/item-data.service";
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -27,7 +27,7 @@ const PROVIDERS = [
ItemDataService, ItemDataService,
DSpaceRESTv2Service, DSpaceRESTv2Service,
ObjectCacheService, ObjectCacheService,
RequestCacheService ResponseCacheService
]; ];
@NgModule({ @NgModule({

View File

@@ -1,12 +1,18 @@
import { combineReducers } from "@ngrx/store"; import { combineReducers } from "@ngrx/store";
import { CacheState, cacheReducer } from "./cache/cache.reducers"; import { CacheState, cacheReducer } from "./cache/cache.reducers";
import { IndexState, indexReducer } from "./index/index.reducers";
import { DataState, dataReducer } from "./data/data.reducers";
export interface CoreState { export interface CoreState {
cache: CacheState cache: CacheState,
index: IndexState,
data: DataState
} }
export const reducers = { export const reducers = {
cache: cacheReducer cache: cacheReducer,
index: indexReducer,
data: dataReducer
}; };
export function coreReducer(state: any, action: any) { export function coreReducer(state: any, action: any) {

View File

@@ -1,41 +0,0 @@
import { Inject, Injectable } from "@angular/core";
import { DataEffects } from "./data.effects";
import { Serializer } from "../serializer";
import { Collection } from "../shared/collection.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { CollectionDataService } from "./collection-data.service";
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
@Injectable()
export class CollectionDataEffects extends DataEffects<Collection> {
constructor(
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig,
actions$: Actions,
restApi: DSpaceRESTv2Service,
cache: ObjectCacheService,
dataService: CollectionDataService
) {
super(EnvConfig, actions$, restApi, cache, dataService);
}
protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/collections';
}
protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/collections/${action.payload.resourceID}`;
}
protected getSerializer(): Serializer<Collection> {
return new DSpaceRESTv2Serializer(Collection);
}
@Effect() findAll$ = this.findAll;
@Effect() findById$ = this.findById;
}

View File

@@ -1,18 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
@Injectable()
export class CollectionDataService extends DataService<Collection> {
serviceName = new OpaqueToken('CollectionDataService');
constructor(
protected objectCache: ObjectCacheService,
protected requestCache: RequestCacheService,
) {
super(Collection);
}
}

View File

@@ -1,68 +0,0 @@
import { Inject } from "@angular/core";
import { Actions } from "@ngrx/effects";
import { Observable } from "rxjs";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "../cache/object-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Serializer } from "../serializer";
import {
RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction,
RequestCacheErrorAction, RequestCacheFindByIDAction
} from "../cache/request-cache.actions";
import { DataService } from "./data.service";
import { hasNoValue } from "../../shared/empty.util";
import { GlobalConfig } from '../../../config';
export abstract class DataEffects<T extends CacheableObject> {
protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string;
protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string;
protected abstract getSerializer(): Serializer<T>;
constructor(
private EnvConfig: GlobalConfig,
private actions$: Actions,
private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService,
private dataService: DataService<T>
) { }
// TODO, results of a findall aren't retrieved from cache yet
protected findAll = this.actions$
.ofType(RequestCacheActionTypes.FIND_ALL)
.filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: RequestCacheFindAllAction) => {
//TODO scope, pagination, sorting -> when we know how that works in rest
return this.restApi.get(this.getFindAllEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserializeArray(data))
.do((ts: T[]) => {
ts.forEach((t) => {
if (hasNoValue(t) || hasNoValue(t.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(t, this.EnvConfig.cache.msToLive);
});
})
.map((ts: Array<T>) => ts.map(t => t.uuid))
.map((ids: Array<string>) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), this.EnvConfig.cache.msToLive))
.catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message)));
});
protected findById = this.actions$
.ofType(RequestCacheActionTypes.FIND_BY_ID)
.filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: RequestCacheFindByIDAction) => {
return this.restApi.get(this.getFindByIdEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data))
.do((t: T) => {
if (hasNoValue(t) || hasNoValue(t.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(t, this.EnvConfig.cache.msToLive);
})
.map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), this.EnvConfig.cache.msToLive))
.catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message)));
});
}

View File

@@ -1,54 +0,0 @@
import { OpaqueToken } from "@angular/core";
import { Observable } from "rxjs";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { ParamHash } from "../shared/param-hash";
import { isNotEmpty } from "../../shared/empty.util";
import { GenericConstructor } from "../shared/generic-constructor";
import { RemoteData } from "./remote-data";
export abstract class DataService<T extends CacheableObject> {
abstract serviceName: OpaqueToken;
protected abstract objectCache: ObjectCacheService;
protected abstract requestCache: RequestCacheService;
constructor(private modelType: GenericConstructor<T>) {
}
findAll(scopeID?: string): RemoteData<Array<T>> {
const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString();
const requestCacheObs = this.requestCache.findAll(key, this.serviceName, scopeID);
return new RemoteData(
requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(),
requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(),
requestCacheObs
.map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
// use those IDs to fetch the actual objects from the ObjectCache
return this.objectCache.getList<T>(resourceUUIDs, this.modelType);
}).distinctUntilChanged()
);
}
findById(id: string): RemoteData<T> {
const key = new ParamHash(this.serviceName, 'findById', id).toString();
const requestCacheObs = this.requestCache.findById(key, this.serviceName, id);
return new RemoteData(
requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(),
requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(),
requestCacheObs
.map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
if (isNotEmpty(resourceUUIDs)) {
return this.objectCache.get<T>(resourceUUIDs[0], this.modelType);
}
else {
return Observable.of(undefined);
}
}).distinctUntilChanged()
);
}
}

View File

@@ -1,41 +0,0 @@
import { Inject, Injectable } from "@angular/core";
import { DataEffects } from "./data.effects";
import { Serializer } from "../serializer";
import { Item } from "../shared/item.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { ItemDataService } from "./item-data.service";
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
@Injectable()
export class ItemDataEffects extends DataEffects<Item> {
constructor(
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig,
actions$: Actions,
restApi: DSpaceRESTv2Service,
cache: ObjectCacheService,
dataService: ItemDataService
) {
super(EnvConfig, actions$, restApi, cache, dataService);
}
protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/items';
}
protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/items/${action.payload.resourceID}`;
}
protected getSerializer(): Serializer<Item> {
return new DSpaceRESTv2Serializer(Item);
}
@Effect() findAll$ = this.findAll;
@Effect() findById$ = this.findById;
}

View File

@@ -1,18 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { DataService } from "./data.service";
import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
@Injectable()
export class ItemDataService extends DataService<Item> {
serviceName = new OpaqueToken('ItemDataService');
constructor(
protected objectCache: ObjectCacheService,
protected requestCache: RequestCacheService,
) {
super(Item);
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { RemoteData } from "./remote-data";
import { ItemDataService } from "./item-data.service";
import { Store } from "@ngrx/store";
import { RequestState } from "./request.reducer";
@Injectable()
export class CollectionDataService extends DataService<Collection> {
protected endpoint = '/collections';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected store: Store<RequestState>,
protected ids: ItemDataService
) {
super(Collection);
}
// findAll(scopeID?: string): RemoteData<Array<Collection>> {
// let remoteData = super.findAll(scopeID);
// remoteData.payload = remoteData.payload.map(collections => {
// return collections.map(collection => {
// collection.items = collection.itemLinks.map(item => {
// return this.ids.findById(item.self);
// });
// return collection
// });
// });
// return remoteData;
// }
}

View File

@@ -0,0 +1,14 @@
import { combineReducers } from "@ngrx/store";
import { RequestState, requestReducer } from "./request.reducer";
export interface DataState {
request: RequestState
}
export const reducers = {
request: requestReducer
};
export function dataReducer(state: any, action: any) {
return combineReducers(reducers)(state, action);
}

View File

@@ -0,0 +1,93 @@
import { Observable } from "rxjs";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { isNotEmpty, hasValue } from "../../shared/empty.util";
import { GenericConstructor } from "../shared/generic-constructor";
import { RemoteData } from "./remote-data";
import { SuccessResponse, ErrorResponse } from "../cache/response-cache.models";
import { FindAllRequest, FindByIDRequest } from "./request.models";
import { RequestState, RequestEntry } from "./request.reducer";
import { Store } from "@ngrx/store";
import { RequestConfigureAction, RequestExecuteAction } from "./request.actions";
import { ResponseCacheEntry } from "../cache/response-cache.reducer";
export abstract class DataService<T extends CacheableObject> {
protected abstract objectCache: ObjectCacheService;
protected abstract responseCache: ResponseCacheService;
protected abstract store: Store<RequestState>;
protected abstract endpoint: string;
constructor(private resourceType: GenericConstructor<T>) {
}
protected getFindAllHref(scopeID?): string {
let result = this.endpoint;
if (hasValue(scopeID)) {
result += `?scope=${scopeID}`
}
return result;
}
findAll(scopeID?: string): RemoteData<Array<T>> {
const href = this.getFindAllHref(scopeID);
const request = new FindAllRequest(href, this.resourceType, scopeID);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
const requestObs = this.store.select('core', 'data', 'request', href);
const responseCacheObs = this.responseCache.get(href);
return new RemoteData(
requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged(),
requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged(),
responseCacheObs
.map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged(),
responseCacheObs
.filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged(),
responseCacheObs
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
// use those IDs to fetch the actual objects from the ObjectCache
return this.objectCache.getList<T>(resourceUUIDs, this.resourceType);
}).distinctUntilChanged()
);
}
protected getFindByIDHref(resourceID): string {
return `${this.endpoint}/${resourceID}`;
}
findById(id: string): RemoteData<T> {
const href = this.getFindByIDHref(id);
const request = new FindByIDRequest(href, this.resourceType, id);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
const requestObs = this.store.select('core', 'data', 'request', href);
const responseCacheObs = this.responseCache.get(href);
return new RemoteData(
requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged(),
requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged(),
responseCacheObs
.map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged(),
responseCacheObs
.filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged(),
responseCacheObs
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
if (isNotEmpty(resourceUUIDs)) {
return this.objectCache.get<T>(resourceUUIDs[0], this.resourceType);
}
else {
return Observable.of(undefined);
}
}).distinctUntilChanged()
);
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { Store } from "@ngrx/store";
import { RequestState } from "./request.reducer";
@Injectable()
export class ItemDataService extends DataService<Item> {
protected endpoint = '/items';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected store: Store<RequestState>
) {
super(Item);
}
}

View File

@@ -2,15 +2,12 @@ import { Injectable } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { StoreActionTypes } from "../../store.actions"; import { StoreActionTypes } from "../../store.actions";
import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions"; import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions";
import { Store } from "@ngrx/store";
import { ObjectCacheState } from "../cache/object-cache.reducer";
@Injectable() @Injectable()
export class ObjectCacheEffects { export class ObjectCacheEffects {
constructor( constructor(
private actions$: Actions, private actions$: Actions
private store: Store<ObjectCacheState>
) { } ) { }
/** /**

View File

@@ -1,8 +1,6 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { hasValue } from "../../shared/empty.util";
export enum RemoteDataState { export enum RemoteDataState {
//TODO RequestPending will never happen: implement it in the store & DataEffects.
RequestPending, RequestPending,
ResponsePending, ResponsePending,
Failed, Failed,
@@ -10,12 +8,14 @@ export enum RemoteDataState {
} }
/** /**
* A class to represent the state of * A class to represent the state of a remote resource
*/ */
export class RemoteData<T> { export class RemoteData<T> {
constructor( constructor(
private storeLoading: Observable<boolean>, private requestPending: Observable<boolean>,
private responsePending: Observable<boolean>,
private isSuccessFul: Observable<boolean>,
public errorMessage: Observable<string>, public errorMessage: Observable<string>,
public payload: Observable<T> public payload: Observable<T>
) { ) {
@@ -23,13 +23,17 @@ export class RemoteData<T> {
get state(): Observable<RemoteDataState> { get state(): Observable<RemoteDataState> {
return Observable.combineLatest( return Observable.combineLatest(
this.storeLoading, this.requestPending,
this.errorMessage.map(msg => hasValue(msg)), this.responsePending,
(storeLoading, hasMsg) => { this.isSuccessFul,
if (storeLoading) { (requestPending, responsePending, isSuccessFul) => {
if (requestPending) {
return RemoteDataState.RequestPending
}
else if (responsePending) {
return RemoteDataState.ResponsePending return RemoteDataState.ResponsePending
} }
else if (hasMsg) { else if (!isSuccessFul) {
return RemoteDataState.Failed return RemoteDataState.Failed
} }
else { else {

View File

@@ -1,16 +1,15 @@
import { Injectable } from "@angular/core"; import { Injectable, Inject } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { ResetRequestCacheTimestampsAction } from "../cache/request-cache.actions";
import { Store } from "@ngrx/store";
import { RequestCacheState } from "../cache/request-cache.reducer";
import { ObjectCacheActionTypes } from "../cache/object-cache.actions"; import { ObjectCacheActionTypes } from "../cache/object-cache.actions";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { ResetResponseCacheTimestampsAction } from "../cache/response-cache.actions";
@Injectable() @Injectable()
export class RequestCacheEffects { export class RequestCacheEffects {
constructor( constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
private actions$: Actions, private actions$: Actions,
private store: Store<RequestCacheState>
) { } ) { }
/** /**
@@ -31,6 +30,5 @@ export class RequestCacheEffects {
*/ */
@Effect() fixTimestampsOnRehydrate = this.actions$ @Effect() fixTimestampsOnRehydrate = this.actions$
.ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS) .ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS)
.map(() => new ResetRequestCacheTimestampsAction(new Date().getTime())); .map(() => new ResetResponseCacheTimestampsAction(new Date().getTime()));
} }

View File

@@ -0,0 +1,61 @@
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Request } from "./request.models";
/**
* The list of RequestAction type definitions
*/
export const RequestActionTypes = {
CONFIGURE: type('dspace/core/data/request/CONFIGURE'),
EXECUTE: type('dspace/core/data/request/EXECUTE'),
COMPLETE: type('dspace/core/data/request/COMPLETE')
};
export class RequestConfigureAction implements Action {
type = RequestActionTypes.CONFIGURE;
payload: Request<CacheableObject>;
constructor(
request: Request<CacheableObject>
) {
this.payload = request;
}
}
export class RequestExecuteAction implements Action {
type = RequestActionTypes.EXECUTE;
payload: string;
constructor(key: string) {
this.payload = key
}
}
/**
* An ngrx action to indicate a response was returned
*/
export class RequestCompleteAction implements Action {
type = RequestActionTypes.COMPLETE;
payload: string;
/**
* Create a new RequestCompleteAction
*
* @param key
* the key under which this request is stored,
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* A type to encompass all RequestActions
*/
export type RequestAction
= RequestConfigureAction
| RequestExecuteAction
| RequestCompleteAction;

View File

@@ -0,0 +1,69 @@
import { Injectable, Inject } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects";
import { Store } from "@ngrx/store";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Observable } from "rxjs";
import { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models";
import { hasNoValue } from "../../shared/empty.util";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { RequestState, RequestEntry } from "./request.reducer";
import {
RequestActionTypes, RequestConfigureAction, RequestExecuteAction,
RequestCompleteAction
} from "./request.actions";
import { ResponseCacheService } from "../cache/response-cache.service";
@Injectable()
export class RequestEffects {
constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
private actions$: Actions,
private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService,
private store: Store<RequestState>
) { }
@Effect() execute = this.actions$
.ofType(RequestActionTypes.EXECUTE)
.flatMap((action: RequestExecuteAction) => {
return this.store.select<RequestEntry>('core', 'data', 'request', action.payload)
.take(1);
})
.flatMap((entry: RequestEntry) => {
const [ifArray, ifNotArray] = this.restApi.get(entry.request.href)
.share() // share ensures restApi.get() doesn't get called twice when the partitions are used below
.partition((data: DSpaceRESTV2Response) => Array.isArray(data._embedded));
return Observable.merge(
ifArray.map((data: DSpaceRESTV2Response) => {
return new DSpaceRESTv2Serializer(entry.request.resourceType).deserializeArray(data);
}).do((cos: CacheableObject[]) => cos.forEach((t) => this.addToObjectCache(t)))
.map((cos: Array<CacheableObject>): Array<string> => cos.map(t => t.uuid)),
ifNotArray.map((data: DSpaceRESTV2Response) => {
return new DSpaceRESTv2Serializer(entry.request.resourceType).deserialize(data);
}).do((co: CacheableObject) => this.addToObjectCache(co))
.map((co: CacheableObject): Array<string> => [co.uuid])
).map((ids: Array<string>) => new SuccessResponse(ids))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href))
.catch((error: Error) => Observable.of(new ErrorResponse(error.message))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href)));
});
protected addToObjectCache(co: CacheableObject): void {
if (hasNoValue(co) || hasNoValue(co.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive);
}
}

View File

@@ -0,0 +1,32 @@
import { SortOptions } from "../shared/sort-options.model";
import { PaginationOptions } from "../shared/pagination-options.model";
import { GenericConstructor } from "../shared/generic-constructor";
export class Request<T> {
constructor(
public href: string,
public resourceType: GenericConstructor<T>
) {}
}
export class FindByIDRequest<T> extends Request<T> {
constructor(
href: string,
resourceType: GenericConstructor<T>,
public resourceID: string
) {
super(href, resourceType);
}
}
export class FindAllRequest<T> extends Request<T> {
constructor(
href: string,
resourceType: GenericConstructor<T>,
public scopeID?: string,
public paginationOptions?: PaginationOptions,
public sortOptions?: SortOptions
) {
super(href, resourceType);
}
}

View File

@@ -0,0 +1,81 @@
import { CacheableObject } from "../cache/object-cache.reducer";
import {
RequestActionTypes, RequestAction, RequestConfigureAction,
RequestExecuteAction, RequestCompleteAction
} from "./request.actions";
import { Request } from "./request.models";
export class RequestEntry {
request: Request<CacheableObject>;
requestPending: boolean;
responsePending: boolean;
completed: boolean;
}
export interface RequestState {
[key: string]: RequestEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
export const requestReducer = (state = initialState, action: RequestAction): RequestState => {
switch (action.type) {
case RequestActionTypes.CONFIGURE: {
return configureRequest(state, <RequestConfigureAction> action);
}
case RequestActionTypes.EXECUTE: {
return executeRequest(state, <RequestExecuteAction> action);
}
case RequestActionTypes.COMPLETE: {
return completeRequest(state, <RequestCompleteAction> action);
}
default: {
return state;
}
}
};
function configureRequest(state: RequestState, action: RequestConfigureAction): RequestState {
return Object.assign({}, state, {
[action.payload.href]: {
request: action.payload,
requestPending: true,
responsePending: false,
completed: false
}
});
}
function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState {
return Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
requestPending: false,
responsePending: true
})
});
}
/**
* Update a request with the response
*
* @param state
* the current state
* @param action
* a RequestCompleteAction
* @return RequestState
* the new state, with the response added to the request
*/
function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState {
return Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
responsePending: false,
completed: true
})
});
}

View File

@@ -55,7 +55,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
if (Array.isArray(response._embedded)) { if (Array.isArray(response._embedded)) {
throw new Error('Expected a single model, use deserializeArray() instead'); throw new Error('Expected a single model, use deserializeArray() instead');
} }
return <T> Deserialize(response._embedded, this.modelType); let normalized = Object.assign({}, response._embedded, this.normalizeLinks(response._links));
return <T> Deserialize(normalized, this.modelType);
} }
/** /**
@@ -70,7 +71,26 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
if (!Array.isArray(response._embedded)) { if (!Array.isArray(response._embedded)) {
throw new Error('Expected an Array, use deserialize() instead'); throw new Error('Expected an Array, use deserialize() instead');
} }
return <Array<T>> Deserialize(response._embedded, this.modelType); let normalized = response._embedded.map((resource) => {
return Object.assign({}, resource, this.normalizeLinks(resource._links));
});
return <Array<T>> Deserialize(normalized, this.modelType);
}
private normalizeLinks(links:any): any {
let normalizedLinks = links;
for (let link in normalizedLinks) {
if (Array.isArray(normalizedLinks[link])) {
normalizedLinks[link] = normalizedLinks[link].map(linkedResource => {
return {'self': linkedResource.href };
});
}
else {
normalizedLinks[link] = normalizedLinks[link].href;
}
}
return normalizedLinks;
} }
} }

View File

@@ -0,0 +1,58 @@
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
/**
* The list of HrefIndexAction type definitions
*/
export const HrefIndexActionTypes = {
ADD: type('dspace/core/index/href/ADD'),
REMOVE_UUID: type('dspace/core/index/href/REMOVE_UUID')
};
/**
* An ngrx action to add an href to the index
*/
export class AddToHrefIndexAction implements Action {
type = HrefIndexActionTypes.ADD;
payload: {
href: string;
uuid: string;
};
/**
* Create a new AddToHrefIndexAction
*
* @param href
* the href to add
* @param uuid
* the uuid of the resource the href links to
*/
constructor(href: string, uuid: string) {
this.payload = { href, uuid };
}
}
/**
* An ngrx action to remove an href from the index
*/
export class RemoveUUIDFromHrefIndexAction implements Action {
type = HrefIndexActionTypes.REMOVE_UUID;
payload: string;
/**
* Create a new RemoveUUIDFromHrefIndexAction
*
* @param uuid
* the uuid to remove all hrefs for
*/
constructor(uuid: string) {
this.payload = uuid;
}
}
/**
* A type to encompass all HrefIndexActions
*/
export type HrefIndexAction
= AddToHrefIndexAction
| RemoveUUIDFromHrefIndexAction;

View File

@@ -0,0 +1,32 @@
import { Injectable } from "@angular/core";
import { Effect, Actions } from "@ngrx/effects";
import {
ObjectCacheActionTypes, AddToObjectCacheAction,
RemoveFromObjectCacheAction
} from "../cache/object-cache.actions";
import { AddToHrefIndexAction, RemoveUUIDFromHrefIndexAction } from "./href-index.actions";
import { hasValue } from "../../shared/empty.util";
@Injectable()
export class HrefIndexEffects {
constructor(
private actions$: Actions
) { }
@Effect() add$ = this.actions$
.ofType(ObjectCacheActionTypes.ADD)
.filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.self))
.map((action: AddToObjectCacheAction) => {
return new AddToHrefIndexAction(
action.payload.objectToCache.self,
action.payload.objectToCache.uuid
);
});
@Effect() remove$ = this.actions$
.ofType(ObjectCacheActionTypes.REMOVE)
.map((action: RemoveFromObjectCacheAction) => {
return new RemoveUUIDFromHrefIndexAction(action.payload);
});
}

View File

@@ -0,0 +1,43 @@
import {
HrefIndexAction, HrefIndexActionTypes, AddToHrefIndexAction,
RemoveUUIDFromHrefIndexAction
} from "./href-index.actions";
export interface HrefIndexState {
[href: string]: string
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: HrefIndexState = Object.create(null);
export const hrefIndexReducer = (state = initialState, action: HrefIndexAction): HrefIndexState => {
switch (action.type) {
case HrefIndexActionTypes.ADD: {
return addToHrefIndex(state, <AddToHrefIndexAction>action);
}
case HrefIndexActionTypes.REMOVE_UUID: {
return removeUUIDFromHrefIndex(state, <RemoveUUIDFromHrefIndexAction>action)
}
default: {
return state;
}
}
};
function addToHrefIndex(state: HrefIndexState, action: AddToHrefIndexAction): HrefIndexState {
return Object.assign({}, state, {
[action.payload.href]: action.payload.uuid
});
}
function removeUUIDFromHrefIndex(state: HrefIndexState, action: RemoveUUIDFromHrefIndexAction): HrefIndexState {
let newState = Object.create(null);
for (let href in state) {
if (state[href] !== action.payload) {
newState[href] = state[href];
}
}
return newState;
}

View File

@@ -0,0 +1,14 @@
import { combineReducers } from "@ngrx/store";
import { HrefIndexState, hrefIndexReducer } from "./href-index.reducer";
export interface IndexState {
href: HrefIndexState
}
export const reducers = {
href: hrefIndexReducer
};
export function indexReducer(state: any, action: any) {
return combineReducers(reducers)(state, action);
}

View File

@@ -1,6 +1,8 @@
import { autoserialize, inheritSerialization } from "cerialize"; import { autoserialize, inheritSerialization, autoserializeAs } from "cerialize";
import { DSpaceObject } from "./dspace-object.model"; import { DSpaceObject } from "./dspace-object.model";
import { Bitstream } from "./bitstream.model"; import { Bitstream } from "./bitstream.model";
import { Item } from "./item.model";
import { RemoteData } from "../data/remote-data";
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Collection extends DSpaceObject { export class Collection extends DSpaceObject {
@@ -54,7 +56,7 @@ export class Collection extends DSpaceObject {
/** /**
* The Bitstream that represents the logo of this Collection * The Bitstream that represents the logo of this Collection
*/ */
logo: Bitstream; logo: RemoteData<Bitstream>;
/** /**
* An array of Collections that are direct parents of this Collection * An array of Collections that are direct parents of this Collection
@@ -66,4 +68,7 @@ export class Collection extends DSpaceObject {
*/ */
owner: Collection; owner: Collection;
@autoserializeAs(RemoteData)
items: RemoteData<Item[]>;
} }

View File

@@ -8,6 +8,9 @@ import { CacheableObject } from "../cache/object-cache.reducer";
*/ */
export abstract class DSpaceObject implements CacheableObject { export abstract class DSpaceObject implements CacheableObject {
@autoserialize
self: string;
/** /**
* The human-readable identifier of this DSpaceObject * The human-readable identifier of this DSpaceObject
*/ */

View File

@@ -1,58 +0,0 @@
import { ParamHash } from "./param-hash";
describe("ParamHash", () => {
it("should return a hash for a set of parameters", () => {
const hash = new ParamHash('azerty', true, 23).toString();
expect(hash).not.toBeNull();
expect(hash).not.toBe('');
});
it("should work with both simple and complex objects as parameters", () => {
const hash = new ParamHash('azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }).toString();
expect(hash).not.toBeNull();
expect(hash).not.toBe('');
});
it("should work with null or undefined as parameters", () => {
const hash1 = new ParamHash(undefined).toString();
const hash2 = new ParamHash(null).toString();
const hash3 = new ParamHash(undefined, null).toString();
expect(hash1).not.toBeNull();
expect(hash1).not.toBe('');
expect(hash2).not.toBeNull();
expect(hash2).not.toBe('');
expect(hash3).not.toBeNull();
expect(hash3).not.toBe('');
expect(hash1).not.toEqual(hash2);
expect(hash1).not.toEqual(hash3);
expect(hash2).not.toEqual(hash3);
});
it("should work if created without parameters", () => {
const hash1 = new ParamHash().toString();
const hash2 = new ParamHash().toString();
expect(hash1).not.toBeNull();
expect(hash1).not.toBe('');
expect(hash1).toEqual(hash2);
});
it("should create the same hash if created with the same set of parameters in the same order", () => {
const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }];
const hash1 = new ParamHash(...params).toString();
const hash2 = new ParamHash(...params).toString();
expect(hash1).toEqual(hash2);
});
it("should create a different hash if created with the same set of parameters in a different order", () => {
const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }];
const hash1 = new ParamHash(...params).toString();
const hash2 = new ParamHash(...params.reverse()).toString();
expect(hash1).not.toEqual(hash2);
});
});

View File

@@ -1,35 +0,0 @@
import { Md5 } from "ts-md5/dist/md5";
/**
* Creates a hash of a set of parameters
*/
export class ParamHash {
private params: Array<any>;
constructor(...params) {
this.params = params;
}
/**
* Returns an md5 hash based on the
* params passed to the constructor
*
* If you hash the same set of params in the
* same order the hashes will be identical
*
* @return {string}
* an md5 hash
*/
toString(): string {
let hash = new Md5();
this.params.forEach((param) => {
if (param === Object(param)) {
hash.appendStr(JSON.stringify(param));
}
else {
hash.appendStr('' + param);
}
});
return hash.end().toString();
}
}

View File

@@ -0,0 +1,10 @@
import { autoserialize } from "cerialize";
export class SelfLink {
@autoserialize
self: string;
@autoserialize
uuid: string;
}

View File

@@ -8,7 +8,7 @@ import { GlobalConfig } from "../../../config";
* TODO write tests once GlobalConfig becomes injectable * TODO write tests once GlobalConfig becomes injectable
*/ */
export class UIURLCombiner extends URLCombiner{ export class UIURLCombiner extends URLCombiner{
constructor(...parts:Array<string>) { constructor(EnvConfig: GlobalConfig, ...parts: Array<string>) {
super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts); super(EnvConfig.ui.baseUrl, EnvConfig.ui.nameSpace, ...parts);
} }
} }

View File

@@ -1,3 +1,22 @@
<div class="home"> <div class="home">
Home component <div *ngIf="collections">
<h3>Collections</h3>
<p *ngIf="(collections.isLoading | async)">Loading…</p>
<p *ngIf="(collections.hasFailed | async)">Failed: {{(remoteData.errorMessage | async)}}</p>
<ul *ngIf="(collections.hasSucceeded | async)">
<li *ngFor="let collection of (collections.payload | async)">
{{collection?.name}}<br>
<span class="text-muted">{{collection?.shortDescription}}</span>
</li>
</ul>
</div>
<div *ngIf="items">
<h3>Items</h3>
<ul>
<li *ngFor="let item of (items.payload | async)">
{{item?.name}}<br>
<span class="text-muted">{{item?.findMetadata('dc.description.abstract')}}</span>
</li>
</ul>
</div>
</div> </div>

View File

@@ -1,4 +1,11 @@
import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; import { Component, ChangeDetectionStrategy, ViewEncapsulation, OnInit } from '@angular/core';
import { Observable } from "rxjs";
import { Collection } from "../core/shared/collection.model";
import { Item } from "../core/shared/item.model";
import { CollectionDataService } from "../core/data/collection-data.service";
import { ItemDataService } from "../core/data/item-data.service";
import { ObjectCacheService } from "../core/cache/object-cache.service";
import { RemoteData } from "../core/data/remote-data";
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
@@ -7,11 +14,16 @@ import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/
styleUrls: ['./home.component.css'], styleUrls: ['./home.component.css'],
templateUrl: './home.component.html' templateUrl: './home.component.html'
}) })
export class HomeComponent { export class HomeComponent implements OnInit {
data: any = {}; data: any = {};
collections: RemoteData<Collection[]>;
items: RemoteData<Item[]>;
constructor() { constructor(
private cds: CollectionDataService,
private ids: ItemDataService,
private objectCache: ObjectCacheService
) {
this.universalInit(); this.universalInit();
} }
@@ -19,4 +31,13 @@ export class HomeComponent {
} }
ngOnInit(): void {
this.collections = this.cds.findAll();
this.items = this.ids.findAll();
this.cds.findById('5179').payload.subscribe(o => console.log('collection 1', o));
this.cds.findById('6547').payload.subscribe(o => console.log('collection 2', o));
this.ids.findById('8871').payload.subscribe(o => console.log('item 1', o));
this.ids.findById('9978').payload.subscribe(o => console.log('item 2', o));
}
} }

View File

@@ -2,9 +2,11 @@ import { NgModule } from '@angular/core';
import { HomeComponent } from './home.component'; import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home-routing.module'; import { HomeRoutingModule } from './home-routing.module';
import { CommonModule } from "@angular/common";
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule,
HomeRoutingModule HomeRoutingModule
], ],
declarations: [ declarations: [