mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
@@ -10,5 +10,13 @@ module.exports = {
|
||||
"ui": {
|
||||
"nameSpace": "/",
|
||||
"baseURL": "http://localhost:3000"
|
||||
},
|
||||
"cache": {
|
||||
// how long should objects be cached for by default
|
||||
"msToLive": 15 * 60 * 1000, //15 minutes
|
||||
},
|
||||
"universal": {
|
||||
//on the client: start with the state on the server
|
||||
"shouldRehydrate": true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -132,6 +132,10 @@ module.exports = function(config) {
|
||||
}
|
||||
},
|
||||
|
||||
mochaReporter: {
|
||||
ignoreSkipped: true
|
||||
}
|
||||
|
||||
/*
|
||||
* Continuous Integration mode
|
||||
* if true, Karma captures browsers, runs the tests and exits
|
||||
|
@@ -111,6 +111,7 @@
|
||||
"ng2-translate": "4.2.0",
|
||||
"preboot": "4.5.2",
|
||||
"rxjs": "5.0.0-beta.12",
|
||||
"ts-md5": "^1.2.0",
|
||||
"webfontloader": "1.6.27",
|
||||
"zone.js": "0.6.26"
|
||||
},
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { EffectsModule } from "@ngrx/effects";
|
||||
import { HeaderEffects } from "./header/header.effects";
|
||||
import { coreEffects } from "./core/core.effects";
|
||||
|
||||
export const effects = [
|
||||
...coreEffects, //TODO should probably be imported in coreModule
|
||||
EffectsModule.run(HeaderEffects)
|
||||
];
|
||||
|
@@ -10,13 +10,6 @@ import { AppComponent } from './app.component';
|
||||
import { HeaderComponent } from './header/header.component';
|
||||
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
|
||||
|
||||
import { StoreModule } from "@ngrx/store";
|
||||
import { RouterStoreModule } from "@ngrx/router-store";
|
||||
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
|
||||
|
||||
import { rootReducer } from './app.reducers';
|
||||
import { effects } from './app.effects';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
@@ -28,34 +21,6 @@ import { effects } from './app.effects';
|
||||
HomeModule,
|
||||
CoreModule.forRoot(),
|
||||
AppRoutingModule,
|
||||
/**
|
||||
* StoreModule.provideStore is imported once in the root module, accepting a reducer
|
||||
* function or object map of reducer functions. If passed an object of
|
||||
* reducers, combineReducers will be run creating your application
|
||||
* meta-reducer. This returns all providers for an @ngrx/store
|
||||
* based application.
|
||||
*/
|
||||
StoreModule.provideStore(rootReducer),
|
||||
|
||||
/**
|
||||
* @ngrx/router-store keeps router state up-to-date in the store and uses
|
||||
* the store as the single source of truth for the router's state.
|
||||
*/
|
||||
RouterStoreModule.connectRouter(),
|
||||
|
||||
/**
|
||||
* Store devtools instrument the store retaining past versions of state
|
||||
* and recalculating new states. This enables powerful time-travel
|
||||
* debugging.
|
||||
*
|
||||
* To use the debugger, install the Redux Devtools extension for either
|
||||
* Chrome or Firefox
|
||||
*
|
||||
* See: https://github.com/zalmoxisus/redux-devtools-extension
|
||||
*/
|
||||
StoreDevtoolsModule.instrumentOnlyWithExtension(),
|
||||
|
||||
effects
|
||||
],
|
||||
providers: [
|
||||
]
|
||||
|
@@ -2,19 +2,28 @@ import { combineReducers } from "@ngrx/store";
|
||||
import { routerReducer, RouterState } from "@ngrx/router-store";
|
||||
import { headerReducer, HeaderState } from './header/header.reducer';
|
||||
import { hostWindowReducer, HostWindowState } from "./shared/host-window.reducer";
|
||||
import { CoreState, coreReducer } from "./core/core.reducers";
|
||||
import { StoreActionTypes } from "./store.actions";
|
||||
|
||||
export interface AppState {
|
||||
core: CoreState;
|
||||
router: RouterState;
|
||||
hostWindow: HostWindowState;
|
||||
header: HeaderState;
|
||||
}
|
||||
|
||||
export const reducers = {
|
||||
core: coreReducer,
|
||||
router: routerReducer,
|
||||
hostWindow: hostWindowReducer,
|
||||
header: headerReducer
|
||||
};
|
||||
|
||||
export function rootReducer(state: any, action: any) {
|
||||
if (action.type === StoreActionTypes.REHYDRATE) {
|
||||
state = action.payload;
|
||||
}
|
||||
return combineReducers(reducers)(state, action);
|
||||
}
|
||||
|
||||
export const NGRX_CACHE_KEY = "NGRX_STORE";
|
||||
|
4
src/app/core/cache/cache-entry.ts
vendored
Normal file
4
src/app/core/cache/cache-entry.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface CacheEntry {
|
||||
timeAdded: number;
|
||||
msToLive: number;
|
||||
}
|
17
src/app/core/cache/cache.reducers.ts
vendored
Normal file
17
src/app/core/cache/cache.reducers.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { combineReducers } from "@ngrx/store";
|
||||
import { RequestCacheState, requestCacheReducer } from "./request-cache.reducer";
|
||||
import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer";
|
||||
|
||||
export interface CacheState {
|
||||
request: RequestCacheState,
|
||||
object: ObjectCacheState
|
||||
}
|
||||
|
||||
export const reducers = {
|
||||
request: requestCacheReducer,
|
||||
object: objectCacheReducer
|
||||
};
|
||||
|
||||
export function cacheReducer(state: any, action: any) {
|
||||
return combineReducers(reducers)(state, action);
|
||||
}
|
82
src/app/core/cache/object-cache.actions.ts
vendored
Normal file
82
src/app/core/cache/object-cache.actions.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Action } from "@ngrx/store";
|
||||
import { type } from "../../shared/ngrx/type";
|
||||
import { CacheableObject } from "./object-cache.reducer";
|
||||
|
||||
/**
|
||||
* The list of ObjectCacheAction type definitions
|
||||
*/
|
||||
export const ObjectCacheActionTypes = {
|
||||
ADD: type('dspace/core/cache/object/ADD'),
|
||||
REMOVE: type('dspace/core/cache/object/REMOVE'),
|
||||
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS')
|
||||
};
|
||||
|
||||
/**
|
||||
* An ngrx action to add an object to the cache
|
||||
*/
|
||||
export class AddToObjectCacheAction implements Action {
|
||||
type = ObjectCacheActionTypes.ADD;
|
||||
payload: {
|
||||
objectToCache: CacheableObject;
|
||||
timeAdded: number;
|
||||
msToLive: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new AddToObjectCacheAction
|
||||
*
|
||||
* @param objectToCache
|
||||
* the object to add
|
||||
* @param timeAdded
|
||||
* the time it was added
|
||||
* @param msToLive
|
||||
* the amount of milliseconds before it should expire
|
||||
*/
|
||||
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number) {
|
||||
this.payload = { objectToCache, timeAdded, msToLive };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove an object from the cache
|
||||
*/
|
||||
export class RemoveFromObjectCacheAction implements Action {
|
||||
type = ObjectCacheActionTypes.REMOVE;
|
||||
payload: string;
|
||||
|
||||
/**
|
||||
* Create a new RemoveFromObjectCacheAction
|
||||
*
|
||||
* @param uuid
|
||||
* the UUID of the object to remove
|
||||
*/
|
||||
constructor(uuid: string) {
|
||||
this.payload = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to reset the timeAdded property of all cached objects
|
||||
*/
|
||||
export class ResetObjectCacheTimestampsAction implements Action {
|
||||
type = ObjectCacheActionTypes.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 ObjectCacheActions
|
||||
*/
|
||||
export type ObjectCacheAction
|
||||
= AddToObjectCacheAction
|
||||
| RemoveFromObjectCacheAction
|
||||
| ResetObjectCacheTimestampsAction;
|
127
src/app/core/cache/object-cache.reducer.spec.ts
vendored
Normal file
127
src/app/core/cache/object-cache.reducer.spec.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as deepFreeze from "deep-freeze";
|
||||
import { objectCacheReducer } from "./object-cache.reducer";
|
||||
import {
|
||||
AddToObjectCacheAction,
|
||||
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
|
||||
} from "./object-cache.actions";
|
||||
|
||||
class NullAction extends RemoveFromObjectCacheAction {
|
||||
type = null;
|
||||
payload = null;
|
||||
|
||||
constructor() {
|
||||
super(null);
|
||||
}
|
||||
}
|
||||
|
||||
describe("objectCacheReducer", () => {
|
||||
const uuid1 = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
||||
const uuid2 = '28b04544-1766-4e82-9728-c4e93544ecd3';
|
||||
const testState = {
|
||||
[uuid1]: {
|
||||
data: {
|
||||
uuid: uuid1,
|
||||
foo: "bar"
|
||||
},
|
||||
timeAdded: new Date().getTime(),
|
||||
msToLive: 900000
|
||||
},
|
||||
[uuid2]: {
|
||||
data: {
|
||||
uuid: uuid2,
|
||||
foo: "baz"
|
||||
},
|
||||
timeAdded: new Date().getTime(),
|
||||
msToLive: 900000
|
||||
}
|
||||
};
|
||||
deepFreeze(testState);
|
||||
|
||||
it("should return the current state when no valid actions have been made", () => {
|
||||
const action = new NullAction();
|
||||
const newState = objectCacheReducer(testState, action);
|
||||
|
||||
expect(newState).toEqual(testState);
|
||||
});
|
||||
|
||||
it("should start with an empty cache", () => {
|
||||
const action = new NullAction();
|
||||
const initialState = objectCacheReducer(undefined, action);
|
||||
|
||||
expect(initialState).toEqual(Object.create(null));
|
||||
});
|
||||
|
||||
it("should add the payload to the cache in response to an ADD action", () => {
|
||||
const state = Object.create(null);
|
||||
const objectToCache = {uuid: uuid1};
|
||||
const timeAdded = new Date().getTime();
|
||||
const msToLive = 900000;
|
||||
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
|
||||
const newState = objectCacheReducer(state, action);
|
||||
|
||||
expect(newState[uuid1].data).toEqual(objectToCache);
|
||||
expect(newState[uuid1].timeAdded).toEqual(timeAdded);
|
||||
expect(newState[uuid1].msToLive).toEqual(msToLive);
|
||||
});
|
||||
|
||||
it("should overwrite an object in the cache in response to an ADD action if it already exists", () => {
|
||||
const objectToCache = {uuid: uuid1, foo: "baz", somethingElse: true};
|
||||
const timeAdded = new Date().getTime();
|
||||
const msToLive = 900000;
|
||||
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
|
||||
const newState = objectCacheReducer(testState, action);
|
||||
|
||||
expect(newState[uuid1].data['foo']).toBe("baz");
|
||||
expect(newState[uuid1].data['somethingElse']).toBe(true);
|
||||
});
|
||||
|
||||
it("should perform the ADD action without affecting the previous state", () => {
|
||||
const state = Object.create(null);
|
||||
const objectToCache = {uuid: uuid1};
|
||||
const timeAdded = new Date().getTime();
|
||||
const msToLive = 900000;
|
||||
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
|
||||
deepFreeze(state);
|
||||
|
||||
objectCacheReducer(state, action);
|
||||
});
|
||||
|
||||
it("should remove the specified object from the cache in response to the REMOVE action", () => {
|
||||
const action = new RemoveFromObjectCacheAction(uuid1);
|
||||
const newState = objectCacheReducer(testState, action);
|
||||
|
||||
expect(testState[uuid1]).not.toBeUndefined();
|
||||
expect(newState[uuid1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shouldn't do anything in response to the REMOVE action for an object that isn't cached", () => {
|
||||
const wrongKey = "this isn't cached";
|
||||
const action = new RemoveFromObjectCacheAction(wrongKey);
|
||||
const newState = objectCacheReducer(testState, action);
|
||||
|
||||
expect(testState[wrongKey]).toBeUndefined();
|
||||
expect(newState).toEqual(testState);
|
||||
});
|
||||
|
||||
it("should perform the REMOVE action without affecting the previous state", () => {
|
||||
const action = new RemoveFromObjectCacheAction(uuid1);
|
||||
//testState has already been frozen above
|
||||
objectCacheReducer(testState, action);
|
||||
});
|
||||
|
||||
it("should set the timestamp of all objects in the cache in response to a RESET_TIMESTAMPS action", () => {
|
||||
const newTimestamp = new Date().getTime();
|
||||
const action = new ResetObjectCacheTimestampsAction(newTimestamp);
|
||||
const newState = objectCacheReducer(testState, action);
|
||||
Object.keys(newState).forEach((key) => {
|
||||
expect(newState[key].timeAdded).toEqual(newTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
it("should perform the RESET_TIMESTAMPS action without affecting the previous state", () => {
|
||||
const action = new ResetObjectCacheTimestampsAction(new Date().getTime());
|
||||
//testState has already been frozen above
|
||||
objectCacheReducer(testState, action);
|
||||
});
|
||||
|
||||
});
|
130
src/app/core/cache/object-cache.reducer.ts
vendored
Normal file
130
src/app/core/cache/object-cache.reducer.ts
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction,
|
||||
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
|
||||
} from "./object-cache.actions";
|
||||
import { hasValue } from "../../shared/empty.util";
|
||||
import { CacheEntry } from "./cache-entry";
|
||||
|
||||
/**
|
||||
* An interface to represent objects that can be cached
|
||||
*
|
||||
* A cacheable object should have a uuid
|
||||
*/
|
||||
export interface CacheableObject {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry in the ObjectCache
|
||||
*/
|
||||
export class ObjectCacheEntry implements CacheEntry {
|
||||
data: CacheableObject;
|
||||
timeAdded: number;
|
||||
msToLive: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ObjectCache State
|
||||
*
|
||||
* Consists of a map with UUIDs as keys,
|
||||
* and ObjectCacheEntries as values
|
||||
*/
|
||||
export interface ObjectCacheState {
|
||||
[uuid: string]: ObjectCacheEntry
|
||||
}
|
||||
|
||||
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
|
||||
const initialState: ObjectCacheState = Object.create(null);
|
||||
|
||||
/**
|
||||
* The ObjectCache Reducer
|
||||
*
|
||||
* @param state
|
||||
* the current state
|
||||
* @param action
|
||||
* the action to perform on the state
|
||||
* @return ObjectCacheState
|
||||
* the new state
|
||||
*/
|
||||
export const objectCacheReducer = (state = initialState, action: ObjectCacheAction): ObjectCacheState => {
|
||||
switch (action.type) {
|
||||
|
||||
case ObjectCacheActionTypes.ADD: {
|
||||
return addToObjectCache(state, <AddToObjectCacheAction>action);
|
||||
}
|
||||
|
||||
case ObjectCacheActionTypes.REMOVE: {
|
||||
return removeFromObjectCache(state, <RemoveFromObjectCacheAction>action)
|
||||
}
|
||||
|
||||
case ObjectCacheActionTypes.RESET_TIMESTAMPS: {
|
||||
return resetObjectCacheTimestamps(state, <ResetObjectCacheTimestampsAction>action)
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an object to the cache
|
||||
*
|
||||
* @param state
|
||||
* the current state
|
||||
* @param action
|
||||
* an AddToObjectCacheAction
|
||||
* @return ObjectCacheState
|
||||
* the new state, with the object added, or overwritten.
|
||||
*/
|
||||
function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState {
|
||||
return Object.assign({}, state, {
|
||||
[action.payload.objectToCache.uuid]: {
|
||||
data: action.payload.objectToCache,
|
||||
timeAdded: action.payload.timeAdded,
|
||||
msToLive: action.payload.msToLive
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an object from the cache
|
||||
*
|
||||
* @param state
|
||||
* the current state
|
||||
* @param action
|
||||
* an RemoveFromObjectCacheAction
|
||||
* @return ObjectCacheState
|
||||
* the new state, with the object removed if it existed.
|
||||
*/
|
||||
function removeFromObjectCache(state: ObjectCacheState, action: RemoveFromObjectCacheAction): ObjectCacheState {
|
||||
if (hasValue(state[action.payload])) {
|
||||
let newObjectCache = Object.assign({}, state);
|
||||
delete newObjectCache[action.payload];
|
||||
|
||||
return newObjectCache;
|
||||
}
|
||||
else {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the timeAdded timestamp of every cached object to the specified value
|
||||
*
|
||||
* @param state
|
||||
* the current state
|
||||
* @param action
|
||||
* a ResetObjectCacheTimestampsAction
|
||||
* @return ObjectCacheState
|
||||
* the new state, with all timeAdded timestamps set to the specified value
|
||||
*/
|
||||
function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObjectCacheTimestampsAction): ObjectCacheState {
|
||||
let newState = Object.create(null);
|
||||
Object.keys(state).forEach(key => {
|
||||
newState[key] = Object.assign({}, state[key], {
|
||||
timeAdded: action.payload
|
||||
});
|
||||
});
|
||||
return newState;
|
||||
}
|
113
src/app/core/cache/object-cache.service.spec.ts
vendored
Normal file
113
src/app/core/cache/object-cache.service.spec.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ObjectCacheState, CacheableObject } from "./object-cache.reducer";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { ObjectCacheService } from "./object-cache.service";
|
||||
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
class TestClass implements CacheableObject {
|
||||
constructor(
|
||||
public uuid: string,
|
||||
public foo: string
|
||||
) {}
|
||||
|
||||
test(): string {
|
||||
return this.foo + this.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ObjectCacheService", () => {
|
||||
let service: ObjectCacheService;
|
||||
let store: Store<ObjectCacheState>;
|
||||
|
||||
const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
||||
const timestamp = new Date().getTime();
|
||||
const msToLive = 900000;
|
||||
const objectToCache = {
|
||||
uuid: uuid,
|
||||
foo: 'bar'
|
||||
};
|
||||
const cacheEntry = {
|
||||
data: objectToCache,
|
||||
timeAdded: timestamp,
|
||||
msToLive: msToLive
|
||||
};
|
||||
const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 });
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Store<ObjectCacheState>(undefined, undefined, undefined);
|
||||
spyOn(store, 'dispatch');
|
||||
service = new ObjectCacheService(store);
|
||||
|
||||
spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp });
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
it("should dispatch an ADD action with the object to add, the time to live, and the current timestamp", () => {
|
||||
|
||||
service.add(objectToCache, msToLive);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive));
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should dispatch a REMOVE action with the UUID of the object to remove", () => {
|
||||
service.remove(uuid);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new RemoveFromObjectCacheAction(uuid));
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should return an observable of the cached object with the specified UUID and type", () => {
|
||||
spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry));
|
||||
|
||||
let testObj: any;
|
||||
//due to the implementation of spyOn above, this subscribe will be synchronous
|
||||
service.get(uuid, TestClass).take(1).subscribe(o => testObj = o);
|
||||
expect(testObj.uuid).toBe(uuid);
|
||||
expect(testObj.foo).toBe("bar");
|
||||
// this only works if testObj is an instance of TestClass
|
||||
expect(testObj.test()).toBe("bar" + uuid);
|
||||
});
|
||||
|
||||
it("should not return a cached object that has exceeded its time to live", () => {
|
||||
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry));
|
||||
|
||||
let getObsHasFired = false;
|
||||
const subscription = service.get(uuid, TestClass).subscribe(o => getObsHasFired = true);
|
||||
expect(getObsHasFired).toBe(false);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getList", () => {
|
||||
it("should return an observable of the array of cached objects with the specified UUID and type", () => {
|
||||
spyOn(service, 'get').and.returnValue(Observable.of(new TestClass(uuid, "bar")));
|
||||
|
||||
let testObjs: Array<any>;
|
||||
service.getList([uuid, uuid], TestClass).take(1).subscribe(arr => testObjs = arr);
|
||||
expect(testObjs[0].uuid).toBe(uuid);
|
||||
expect(testObjs[0].foo).toBe("bar");
|
||||
expect(testObjs[0].test()).toBe("bar" + uuid);
|
||||
expect(testObjs[1].uuid).toBe(uuid);
|
||||
expect(testObjs[1].foo).toBe("bar");
|
||||
expect(testObjs[1].test()).toBe("bar" + uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("has", () => {
|
||||
it("should return true if the object with the supplied UUID is cached and still valid", () => {
|
||||
spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry));
|
||||
expect(service.has(uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the object with the supplied UUID isn't cached", () => {
|
||||
spyOn(store, 'select').and.returnValue(Observable.of(undefined));
|
||||
expect(service.has(uuid)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the object with the supplied UUID is cached but has exceeded its time to live", () => {
|
||||
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry));
|
||||
expect(service.has(uuid)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
130
src/app/core/cache/object-cache.service.ts
vendored
Normal file
130
src/app/core/cache/object-cache.service.ts
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { ObjectCacheState, ObjectCacheEntry, CacheableObject } from "./object-cache.reducer";
|
||||
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions";
|
||||
import { Observable } from "rxjs";
|
||||
import { hasNoValue } from "../../shared/empty.util";
|
||||
import { GenericConstructor } from "../shared/generic-constructor";
|
||||
|
||||
/**
|
||||
* A service to interact with the object cache
|
||||
*/
|
||||
@Injectable()
|
||||
export class ObjectCacheService {
|
||||
constructor(
|
||||
private store: Store<ObjectCacheState>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Add an object to the cache
|
||||
*
|
||||
* @param objectToCache
|
||||
* The object to add
|
||||
* @param msToLive
|
||||
* The number of milliseconds it should be cached for
|
||||
*/
|
||||
add(objectToCache: CacheableObject, msToLive: number): void {
|
||||
this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the object with the supplied UUID from the cache
|
||||
*
|
||||
* @param uuid
|
||||
* The UUID of the object to be removed
|
||||
*/
|
||||
remove(uuid: string): void {
|
||||
this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an observable of the object with the specified UUID
|
||||
*
|
||||
* The type needs to be specified as well, in order to turn
|
||||
* the cached plain javascript object in to an instance of
|
||||
* a class.
|
||||
*
|
||||
* e.g. get('c96588c6-72d3-425d-9d47-fa896255a695', Item)
|
||||
*
|
||||
* @param uuid
|
||||
* The UUID of the object to get
|
||||
* @param type
|
||||
* The type of the object to get
|
||||
* @return Observable<T>
|
||||
* An observable of the requested object
|
||||
*/
|
||||
get<T extends CacheableObject>(uuid: string, type: GenericConstructor<T>): Observable<T> {
|
||||
return this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid)
|
||||
.filter(entry => this.isValid(entry))
|
||||
.distinctUntilChanged()
|
||||
.map((entry: ObjectCacheEntry) => <T> Object.assign(new type(), entry.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an observable for an array of objects of the same type
|
||||
* with the specified UUIDs
|
||||
*
|
||||
* The type needs to be specified as well, in order to turn
|
||||
* the cached plain javascript object in to an instance of
|
||||
* a class.
|
||||
*
|
||||
* e.g. getList([
|
||||
* 'c96588c6-72d3-425d-9d47-fa896255a695',
|
||||
* 'cff860da-cf5f-4fda-b8c9-afb7ec0b2d9e'
|
||||
* ], Collection)
|
||||
*
|
||||
* @param uuids
|
||||
* An array of UUIDs of the objects to get
|
||||
* @param type
|
||||
* The type of the objects to get
|
||||
* @return Observable<Array<T>>
|
||||
*/
|
||||
getList<T extends CacheableObject>(uuids: Array<string>, type: GenericConstructor<T>): Observable<Array<T>> {
|
||||
return Observable.combineLatest(
|
||||
uuids.map((id: string) => this.get<T>(id, type))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the object with the specified UUID is cached
|
||||
*
|
||||
* @param uuid
|
||||
* The UUID of the object to check
|
||||
* @return boolean
|
||||
* true if the object with the specified UUID is cached,
|
||||
* false otherwise
|
||||
*/
|
||||
has(uuid: string): boolean {
|
||||
let result: boolean;
|
||||
|
||||
this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid)
|
||||
.take(1)
|
||||
.subscribe(entry => result = this.isValid(entry));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an ObjectCacheEntry 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: ObjectCacheEntry): 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 RemoveFromObjectCacheAction(entry.data.uuid));
|
||||
}
|
||||
return !isOutDated;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
205
src/app/core/cache/request-cache.actions.ts
vendored
Normal file
205
src/app/core/cache/request-cache.actions.ts
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
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;
|
227
src/app/core/cache/request-cache.reducer.spec.ts
vendored
Normal file
227
src/app/core/cache/request-cache.reducer.spec.ts
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
212
src/app/core/cache/request-cache.reducer.ts
vendored
Normal file
212
src/app/core/cache/request-cache.reducer.ts
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
}
|
147
src/app/core/cache/request-cache.service.spec.ts
vendored
Normal file
147
src/app/core/cache/request-cache.service.spec.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
138
src/app/core/cache/request-cache.service.ts
vendored
Normal file
138
src/app/core/cache/request-cache.service.ts
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
12
src/app/core/core.effects.ts
Normal file
12
src/app/core/core.effects.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EffectsModule } from "@ngrx/effects";
|
||||
import { CollectionDataEffects } from "./data-services/collection-data.effects";
|
||||
import { ItemDataEffects } from "./data-services/item-data.effects";
|
||||
import { ObjectCacheEffects } from "./data-services/object-cache.effects";
|
||||
import { RequestCacheEffects } from "./data-services/request-cache.effects";
|
||||
|
||||
export const coreEffects = [
|
||||
EffectsModule.run(CollectionDataEffects),
|
||||
EffectsModule.run(ItemDataEffects),
|
||||
EffectsModule.run(RequestCacheEffects),
|
||||
EffectsModule.run(ObjectCacheEffects),
|
||||
];
|
@@ -3,6 +3,11 @@ import { CommonModule } from '@angular/common';
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
import { isNotEmpty } from "../shared/empty.util";
|
||||
import { FooterComponent } from "./footer/footer.component";
|
||||
import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service";
|
||||
import { ObjectCacheService } from "./cache/object-cache.service";
|
||||
import { RequestCacheService } from "./cache/request-cache.service";
|
||||
import { CollectionDataService } from "./data-services/collection-data.service";
|
||||
import { ItemDataService } from "./data-services/item-data.service";
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
@@ -18,6 +23,11 @@ const EXPORTS = [
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
CollectionDataService,
|
||||
ItemDataService,
|
||||
DSpaceRESTv2Service,
|
||||
ObjectCacheService,
|
||||
RequestCacheService
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
14
src/app/core/core.reducers.ts
Normal file
14
src/app/core/core.reducers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { combineReducers } from "@ngrx/store";
|
||||
import { CacheState, cacheReducer } from "./cache/cache.reducers";
|
||||
|
||||
export interface CoreState {
|
||||
cache: CacheState
|
||||
}
|
||||
|
||||
export const reducers = {
|
||||
cache: cacheReducer
|
||||
};
|
||||
|
||||
export function coreReducer(state: any, action: any) {
|
||||
return combineReducers(reducers)(state, action);
|
||||
}
|
38
src/app/core/data-services/collection-data.effects.ts
Normal file
38
src/app/core/data-services/collection-data.effects.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { 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";
|
||||
|
||||
@Injectable()
|
||||
export class CollectionDataEffects extends DataEffects<Collection> {
|
||||
constructor(
|
||||
actions$: Actions,
|
||||
restApi: DSpaceRESTv2Service,
|
||||
cache: ObjectCacheService,
|
||||
dataService: CollectionDataService
|
||||
) {
|
||||
super(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;
|
||||
}
|
18
src/app/core/data-services/collection-data.service.ts
Normal file
18
src/app/core/data-services/collection-data.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
65
src/app/core/data-services/data.effects.ts
Normal file
65
src/app/core/data-services/data.effects.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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 { GlobalConfig } from "../../../config";
|
||||
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";
|
||||
|
||||
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 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, GlobalConfig.cache.msToLive);
|
||||
});
|
||||
})
|
||||
.map((ts: Array<T>) => ts.map(t => t.uuid))
|
||||
.map((ids: Array<string>) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), GlobalConfig.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, GlobalConfig.cache.msToLive);
|
||||
})
|
||||
.map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), GlobalConfig.cache.msToLive))
|
||||
.catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message)));
|
||||
});
|
||||
|
||||
}
|
54
src/app/core/data-services/data.service.ts
Normal file
54
src/app/core/data-services/data.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
38
src/app/core/data-services/item-data.effects.ts
Normal file
38
src/app/core/data-services/item-data.effects.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { 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";
|
||||
|
||||
@Injectable()
|
||||
export class ItemDataEffects extends DataEffects<Item> {
|
||||
constructor(
|
||||
actions$: Actions,
|
||||
restApi: DSpaceRESTv2Service,
|
||||
cache: ObjectCacheService,
|
||||
dataService: ItemDataService
|
||||
) {
|
||||
super(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;
|
||||
}
|
18
src/app/core/data-services/item-data.service.ts
Normal file
18
src/app/core/data-services/item-data.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
28
src/app/core/data-services/object-cache.effects.ts
Normal file
28
src/app/core/data-services/object-cache.effects.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Actions, Effect } from "@ngrx/effects";
|
||||
import { StoreActionTypes } from "../../store.actions";
|
||||
import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { ObjectCacheState } from "../cache/object-cache.reducer";
|
||||
|
||||
@Injectable()
|
||||
export class ObjectCacheEffects {
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private store: Store<ObjectCacheState>
|
||||
) { }
|
||||
|
||||
/**
|
||||
* When the store is rehydrated in the browser, set all cache
|
||||
* timestamps to "now", because the time zone of the server can
|
||||
* differ from the client.
|
||||
*
|
||||
* This assumes that the server cached everything a negligible
|
||||
* time ago, and will likely need to be revisited later
|
||||
*/
|
||||
@Effect() fixTimestampsOnRehydrate = this.actions$
|
||||
.ofType(StoreActionTypes.REHYDRATE)
|
||||
.map(() => new ResetObjectCacheTimestampsAction(new Date().getTime()));
|
||||
|
||||
}
|
75
src/app/core/data-services/remote-data.ts
Normal file
75
src/app/core/data-services/remote-data.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { hasValue } from "../../shared/empty.util";
|
||||
|
||||
export enum RemoteDataState {
|
||||
//TODO RequestPending will never happen: implement it in the store & DataEffects.
|
||||
RequestPending,
|
||||
ResponsePending,
|
||||
Failed,
|
||||
Success
|
||||
}
|
||||
|
||||
/**
|
||||
* A class to represent the state of
|
||||
*/
|
||||
export class RemoteData<T> {
|
||||
|
||||
constructor(
|
||||
private storeLoading: Observable<boolean>,
|
||||
public errorMessage: Observable<string>,
|
||||
public payload: Observable<T>
|
||||
) {
|
||||
}
|
||||
|
||||
get state(): Observable<RemoteDataState> {
|
||||
return Observable.combineLatest(
|
||||
this.storeLoading,
|
||||
this.errorMessage.map(msg => hasValue(msg)),
|
||||
(storeLoading, hasMsg) => {
|
||||
if (storeLoading) {
|
||||
return RemoteDataState.ResponsePending
|
||||
}
|
||||
else if (hasMsg) {
|
||||
return RemoteDataState.Failed
|
||||
}
|
||||
else {
|
||||
return RemoteDataState.Success
|
||||
}
|
||||
}
|
||||
).distinctUntilChanged();
|
||||
}
|
||||
|
||||
get isRequestPending(): Observable<boolean> {
|
||||
return this.state
|
||||
.map(state => state == RemoteDataState.RequestPending)
|
||||
.distinctUntilChanged();
|
||||
}
|
||||
|
||||
get isResponsePending(): Observable<boolean> {
|
||||
return this.state
|
||||
.map(state => state == RemoteDataState.ResponsePending)
|
||||
.distinctUntilChanged();
|
||||
}
|
||||
|
||||
get isLoading(): Observable<boolean> {
|
||||
return this.state
|
||||
.map(state => {
|
||||
return state == RemoteDataState.RequestPending
|
||||
|| state === RemoteDataState.ResponsePending
|
||||
})
|
||||
.distinctUntilChanged();
|
||||
}
|
||||
|
||||
get hasFailed(): Observable<boolean> {
|
||||
return this.state
|
||||
.map(state => state == RemoteDataState.Failed)
|
||||
.distinctUntilChanged();
|
||||
}
|
||||
|
||||
get hasSucceeded(): Observable<boolean> {
|
||||
return this.state
|
||||
.map(state => state == RemoteDataState.Success)
|
||||
.distinctUntilChanged();
|
||||
}
|
||||
|
||||
}
|
36
src/app/core/data-services/request-cache.effects.ts
Normal file
36
src/app/core/data-services/request-cache.effects.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
export class RequestCacheEffects {
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private store: Store<RequestCacheState>
|
||||
) { }
|
||||
|
||||
/**
|
||||
* When the store is rehydrated in the browser, set all cache
|
||||
* timestamps to "now", because the time zone of the server can
|
||||
* differ from the client.
|
||||
*
|
||||
* This assumes that the server cached everything a negligible
|
||||
* time ago, and will likely need to be revisited later
|
||||
*
|
||||
* This effect should listen for StoreActionTypes.REHYDRATE,
|
||||
* but can't because you can only have one effect listen to
|
||||
* an action atm. Github issue:
|
||||
* https://github.com/ngrx/effects/issues/87
|
||||
*
|
||||
* It's listening for ObjectCacheActionTypes.RESET_TIMESTAMPS
|
||||
* instead, until there's a solution.
|
||||
*/
|
||||
@Effect() fixTimestampsOnRehydrate = this.actions$
|
||||
.ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS)
|
||||
.map(() => new ResetRequestCacheTimestampsAction(new Date().getTime()));
|
||||
|
||||
}
|
@@ -2,13 +2,7 @@ import { Serialize, Deserialize } from "cerialize";
|
||||
import { Serializer } from "../serializer";
|
||||
import { DSpaceRESTV2Response } from "./dspace-rest-v2-response.model";
|
||||
import { DSpaceRESTv2Validator } from "./dspace-rest-v2.validator";
|
||||
|
||||
/**
|
||||
* ensures we can use 'typeof T' as a type
|
||||
* more details:
|
||||
* https://github.com/Microsoft/TypeScript/issues/204#issuecomment-257722306
|
||||
*/
|
||||
type Constructor<T> = { new (...args: any[]): T } | ((...args: any[]) => T) | Function;
|
||||
import { GenericConstructor } from "../shared/generic-constructor";
|
||||
|
||||
/**
|
||||
* This Serializer turns responses from v2 of DSpace's REST API
|
||||
@@ -22,7 +16,7 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
|
||||
* @param modelType a class or interface to indicate
|
||||
* the kind of model this serializer should work with
|
||||
*/
|
||||
constructor(private modelType: Constructor<T>) {
|
||||
constructor(private modelType: GenericConstructor<T>) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
34
src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
Normal file
34
src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Http, RequestOptionsArgs } from '@angular/http';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RESTURLCombiner } from "../url-combiner/rest-url-combiner";
|
||||
|
||||
/**
|
||||
* Service to access DSpace's REST API
|
||||
*/
|
||||
@Injectable()
|
||||
export class DSpaceRESTv2Service {
|
||||
constructor(public _http: Http) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a request to the REST API with the `get` http method.
|
||||
*
|
||||
* @param relativeURL
|
||||
* A URL, relative to the basepath of the rest api
|
||||
* @param options
|
||||
* A RequestOptionsArgs object, with options for the http call.
|
||||
* @return {Observable<string>}
|
||||
* An Observablse<string> containing the response from the server
|
||||
*/
|
||||
get(relativeURL: string, options?: RequestOptionsArgs): Observable<string> {
|
||||
return this._http.get(new RESTURLCombiner(relativeURL).toString(), options)
|
||||
.map(res => res.json())
|
||||
.catch(err => {
|
||||
console.log('Error: ', err);
|
||||
return Observable.throw(err);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
37
src/app/core/shared/bitstream.model.ts
Normal file
37
src/app/core/shared/bitstream.model.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bundle } from "./bundle.model";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Bitstream extends DSpaceObject {
|
||||
|
||||
/**
|
||||
* The size of this bitstream in bytes(?)
|
||||
*/
|
||||
size: number;
|
||||
|
||||
/**
|
||||
* The relative path to this Bitstream's file
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* The mime type of this Bitstream
|
||||
*/
|
||||
mimetype: string;
|
||||
|
||||
/**
|
||||
* The description of this Bitstream
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* An array of Bundles that are direct parents of this Bitstream
|
||||
*/
|
||||
parents: Array<Bundle>;
|
||||
|
||||
/**
|
||||
* The Bundle that owns this Bitstream
|
||||
*/
|
||||
owner: Bundle;
|
||||
}
|
23
src/app/core/shared/bundle.model.ts
Normal file
23
src/app/core/shared/bundle.model.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bitstream } from "./bitstream.model";
|
||||
import { Item } from "./item.model";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Bundle extends DSpaceObject {
|
||||
/**
|
||||
* The primary bitstream of this Bundle
|
||||
*/
|
||||
primaryBitstream: Bitstream;
|
||||
|
||||
/**
|
||||
* An array of Items that are direct parents of this Bundle
|
||||
*/
|
||||
parents: Array<Item>;
|
||||
|
||||
/**
|
||||
* The Item that owns this Bundle
|
||||
*/
|
||||
owner: Item;
|
||||
|
||||
}
|
69
src/app/core/shared/collection.model.ts
Normal file
69
src/app/core/shared/collection.model.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { autoserialize, inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bitstream } from "./bitstream.model";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Collection extends DSpaceObject {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
handle: string;
|
||||
|
||||
/**
|
||||
* The introductory text of this Collection
|
||||
* Corresponds to the metadata field dc.description
|
||||
*/
|
||||
get introductoryText(): string {
|
||||
return this.findMetadata("dc.description");
|
||||
}
|
||||
|
||||
/**
|
||||
* The short description: HTML
|
||||
* Corresponds to the metadata field dc.description.abstract
|
||||
*/
|
||||
get shortDescription(): string {
|
||||
return this.findMetadata("dc.description.abstract");
|
||||
}
|
||||
|
||||
/**
|
||||
* The copyright text of this Collection
|
||||
* Corresponds to the metadata field dc.rights
|
||||
*/
|
||||
get copyrightText(): string {
|
||||
return this.findMetadata("dc.rights");
|
||||
}
|
||||
|
||||
/**
|
||||
* The license of this Collection
|
||||
* Corresponds to the metadata field dc.rights.license
|
||||
*/
|
||||
get license(): string {
|
||||
return this.findMetadata("dc.rights.license");
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar text of this Collection
|
||||
* Corresponds to the metadata field dc.description.tableofcontents
|
||||
*/
|
||||
get sidebarText(): string {
|
||||
return this.findMetadata("dc.description.tableofcontents");
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bitstream that represents the logo of this Collection
|
||||
*/
|
||||
logo: Bitstream;
|
||||
|
||||
/**
|
||||
* An array of Collections that are direct parents of this Collection
|
||||
*/
|
||||
parents: Array<Collection>;
|
||||
|
||||
/**
|
||||
* The Collection that owns this Collection
|
||||
*/
|
||||
owner: Collection;
|
||||
|
||||
}
|
74
src/app/core/shared/dspace-object.model.ts
Normal file
74
src/app/core/shared/dspace-object.model.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { autoserialize, autoserializeAs } from "cerialize";
|
||||
import { Metadatum } from "./metadatum.model"
|
||||
import { isEmpty, isNotEmpty } from "../../shared/empty.util";
|
||||
import { CacheableObject } from "../cache/object-cache.reducer";
|
||||
|
||||
/**
|
||||
* An abstract model class for a DSpaceObject.
|
||||
*/
|
||||
export abstract class DSpaceObject implements CacheableObject {
|
||||
|
||||
/**
|
||||
* The human-readable identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The universally unique identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
uuid: string;
|
||||
|
||||
/**
|
||||
* A string representing the kind of DSpaceObject, e.g. community, item, …
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* The name for this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An array containing all metadata of this DSpaceObject
|
||||
*/
|
||||
@autoserializeAs(Metadatum)
|
||||
metadata: Array<Metadatum>;
|
||||
|
||||
/**
|
||||
* An array of DSpaceObjects that are direct parents of this DSpaceObject
|
||||
*/
|
||||
parents: Array<DSpaceObject>;
|
||||
|
||||
/**
|
||||
* The DSpaceObject that owns this DSpaceObject
|
||||
*/
|
||||
owner: DSpaceObject;
|
||||
|
||||
/**
|
||||
* Find a metadata field by key and language
|
||||
*
|
||||
* This method returns the value of the first element
|
||||
* in the metadata array that matches the provided
|
||||
* key and language
|
||||
*
|
||||
* @param key
|
||||
* @param language
|
||||
* @return string
|
||||
*/
|
||||
findMetadata(key: string, language?: string): string {
|
||||
const metadatum = this.metadata
|
||||
.find((metadatum: Metadatum) => {
|
||||
return metadatum.key === key &&
|
||||
(isEmpty(language) || metadatum.language === language)
|
||||
});
|
||||
if (isNotEmpty(metadatum)) {
|
||||
return metadatum.value;
|
||||
}
|
||||
else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
7
src/app/core/shared/generic-constructor.ts
Normal file
7
src/app/core/shared/generic-constructor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* ensures we can use 'typeof T' as a type
|
||||
* more details:
|
||||
* https://github.com/Microsoft/TypeScript/issues/204#issuecomment-257722306
|
||||
*/
|
||||
export type GenericConstructor<T> = { new (...args: any[]): T };
|
||||
|
39
src/app/core/shared/item.model.ts
Normal file
39
src/app/core/shared/item.model.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { inheritSerialization, autoserialize } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Collection } from "./collection.model";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Item extends DSpaceObject {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Item
|
||||
*/
|
||||
@autoserialize
|
||||
handle: string;
|
||||
|
||||
/**
|
||||
* The Date of the last modification of this Item
|
||||
*/
|
||||
lastModified: Date;
|
||||
|
||||
/**
|
||||
* A boolean representing if this Item is currently archived or not
|
||||
*/
|
||||
isArchived: boolean;
|
||||
|
||||
/**
|
||||
* A boolean representing if this Item is currently withdrawn or not
|
||||
*/
|
||||
isWithdrawn: boolean;
|
||||
|
||||
/**
|
||||
* An array of Collections that are direct parents of this Item
|
||||
*/
|
||||
parents: Array<Collection>;
|
||||
|
||||
/**
|
||||
* The Collection that owns this Item
|
||||
*/
|
||||
owner: Collection;
|
||||
|
||||
}
|
21
src/app/core/shared/metadatum.model.ts
Normal file
21
src/app/core/shared/metadatum.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { autoserialize } from "cerialize";
|
||||
export class Metadatum {
|
||||
|
||||
/**
|
||||
* The metadata field of this Metadatum
|
||||
*/
|
||||
@autoserialize
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* The language of this Metadatum
|
||||
*/
|
||||
@autoserialize
|
||||
language: string;
|
||||
|
||||
/**
|
||||
* The value of this Metadatum
|
||||
*/
|
||||
@autoserialize
|
||||
value: string;
|
||||
}
|
12
src/app/core/shared/pagination-options.model.ts
Normal file
12
src/app/core/shared/pagination-options.model.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class PaginationOptions {
|
||||
/**
|
||||
* The number of results per page.
|
||||
*/
|
||||
resultsPerPage: number = 10;
|
||||
|
||||
/**
|
||||
* The active page.
|
||||
*/
|
||||
currentPage: number = 1;
|
||||
|
||||
}
|
58
src/app/core/shared/param-hash.spec.ts
Normal file
58
src/app/core/shared/param-hash.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
});
|
||||
});
|
35
src/app/core/shared/param-hash.ts
Normal file
35
src/app/core/shared/param-hash.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
}
|
||||
}
|
9
src/app/core/shared/sort-options.model.ts
Normal file
9
src/app/core/shared/sort-options.model.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum SortDirection {
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
||||
|
||||
export class SortOptions {
|
||||
field: string = "id";
|
||||
direction: SortDirection = SortDirection.Ascending
|
||||
}
|
14
src/app/core/url-combiner/rest-url-combiner.ts
Normal file
14
src/app/core/url-combiner/rest-url-combiner.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { URLCombiner } from "./url-combiner";
|
||||
import { GlobalConfig } from "../../../config";
|
||||
|
||||
/**
|
||||
* Combines a variable number of strings representing parts
|
||||
* of a relative REST URL in to a single, absolute REST URL
|
||||
*
|
||||
* TODO write tests once GlobalConfig becomes injectable
|
||||
*/
|
||||
export class RESTURLCombiner extends URLCombiner{
|
||||
constructor(...parts:Array<string>) {
|
||||
super(GlobalConfig.rest.baseURL, GlobalConfig.rest.nameSpace, ...parts);
|
||||
}
|
||||
}
|
14
src/app/core/url-combiner/ui-url-combiner.ts
Normal file
14
src/app/core/url-combiner/ui-url-combiner.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { URLCombiner } from "./url-combiner";
|
||||
import { GlobalConfig } from "../../../config";
|
||||
|
||||
/**
|
||||
* Combines a variable number of strings representing parts
|
||||
* of a relative UI URL in to a single, absolute UI URL
|
||||
*
|
||||
* TODO write tests once GlobalConfig becomes injectable
|
||||
*/
|
||||
export class UIURLCombiner extends URLCombiner{
|
||||
constructor(...parts:Array<string>) {
|
||||
super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts);
|
||||
}
|
||||
}
|
33
src/app/core/url-combiner/url-combiner.spec.ts
Normal file
33
src/app/core/url-combiner/url-combiner.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { URLCombiner } from "./url-combiner";
|
||||
|
||||
describe("URLCombiner", () => {
|
||||
|
||||
it("should return a valid URL when created with a valid set of url parts", () => {
|
||||
const url = new URLCombiner('http://foo.com', 'bar', 'id', '5').toString();
|
||||
expect(url).toBe('http://foo.com/bar/id/5');
|
||||
});
|
||||
|
||||
it("should return a URL with the protocol followed by two slashes", () => {
|
||||
const url = new URLCombiner('http:/foo.com').toString();
|
||||
expect(url).toBe('http://foo.com');
|
||||
});
|
||||
|
||||
it("should return a URL with a single slash between each part", () => {
|
||||
const url = new URLCombiner('http://foo.com/', '/bar/', '//id', '///5').toString();
|
||||
expect(url).toBe('http://foo.com/bar/id/5');
|
||||
});
|
||||
|
||||
it("should return a URL without a trailing slash before its parameters", () => {
|
||||
const url1 = new URLCombiner('http://foo.com/', '?bar=25').toString();
|
||||
const url2 = new URLCombiner('http://foo.com/', '#bar').toString();
|
||||
|
||||
expect(url1).toBe('http://foo.com?bar=25');
|
||||
expect(url2).toBe('http://foo.com#bar');
|
||||
});
|
||||
|
||||
it("should return an empty string when created without url parts", () => {
|
||||
const url = new URLCombiner().toString();
|
||||
expect(url).toBe('');
|
||||
});
|
||||
|
||||
});
|
55
src/app/core/url-combiner/url-combiner.ts
Normal file
55
src/app/core/url-combiner/url-combiner.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { isEmpty } from "../../shared/empty.util";
|
||||
|
||||
/**
|
||||
* Combines a variable number of strings representing parts
|
||||
* of a URL in to a single, normalized URL
|
||||
*/
|
||||
export class URLCombiner {
|
||||
private parts: Array<string>;
|
||||
|
||||
/**
|
||||
* Creates a new URLCombiner
|
||||
*
|
||||
* @param parts
|
||||
* a variable number of strings representing parts of a URL
|
||||
*/
|
||||
constructor(...parts:Array<string>) {
|
||||
// can't do this in the constructor signature,
|
||||
// because of the spread operator
|
||||
this.parts = parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the parts of this URLCombiner in to a single,
|
||||
* normalized URL
|
||||
*
|
||||
* e.g. new URLCombiner('http:/foo.com/', '/bar', 'id', '5').toString()
|
||||
* returns: http://foo.com/bar/id/5
|
||||
*
|
||||
* @return {string}
|
||||
* The combined URL
|
||||
*/
|
||||
toString(): string {
|
||||
if (isEmpty(this.parts)) {
|
||||
return '';
|
||||
}
|
||||
else {
|
||||
let url = this.parts.join('/');
|
||||
|
||||
// make sure protocol is followed by two slashes
|
||||
url = url.replace(/:\//g, '://');
|
||||
|
||||
// remove consecutive slashes
|
||||
url = url.replace(/([^:\s])\/+/g, '$1/');
|
||||
|
||||
// remove trailing slash before parameters or hash
|
||||
url = url.replace(/\/(\?|&|#[^!])/g, '$1');
|
||||
|
||||
// replace ? in parameters with &
|
||||
url = url.replace(/(\?.+)\?/g, '$1&');
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,88 +0,0 @@
|
||||
import { Inject, Injectable, isDevMode } from '@angular/core';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
static KEY = 'CacheService';
|
||||
|
||||
constructor( @Inject('LRU') public _cache: Map<string, any>) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* check if there is a value in our store
|
||||
*/
|
||||
has(key: string | number): boolean {
|
||||
let _key = this.normalizeKey(key);
|
||||
return this._cache.has(_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* store our state
|
||||
*/
|
||||
set(key: string | number, value: any): void {
|
||||
let _key = this.normalizeKey(key);
|
||||
this._cache.set(_key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* get our cached value
|
||||
*/
|
||||
get(key: string | number): any {
|
||||
let _key = this.normalizeKey(key);
|
||||
return this._cache.get(_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* release memory refs
|
||||
*/
|
||||
clear(): void {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* convert to json for the client
|
||||
*/
|
||||
dehydrate(): any {
|
||||
let json = {};
|
||||
this._cache.forEach((value: any, key: string) => json[key] = value);
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert server json into out initial state
|
||||
*/
|
||||
rehydrate(json: any): void {
|
||||
Object.keys(json).forEach((key: string) => {
|
||||
let _key = this.normalizeKey(key);
|
||||
let value = json[_key];
|
||||
this._cache.set(_key, value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* allow JSON.stringify to work
|
||||
*/
|
||||
toJSON(): any {
|
||||
return this.dehydrate();
|
||||
}
|
||||
|
||||
/**
|
||||
* convert numbers into strings
|
||||
*/
|
||||
normalizeKey(key: string | number): string {
|
||||
if (isDevMode() && this._isInvalidValue(key)) {
|
||||
throw new Error('Please provide a valid key to save in the CacheService');
|
||||
}
|
||||
|
||||
return key + '';
|
||||
}
|
||||
|
||||
_isInvalidValue(key): boolean {
|
||||
return key === null ||
|
||||
key === undefined ||
|
||||
key === 0 ||
|
||||
key === '' ||
|
||||
typeof key === 'boolean' ||
|
||||
Number.isNaN(<number>key);
|
||||
}
|
||||
}
|
@@ -1,55 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/of';
|
||||
import 'rxjs/add/operator/do';
|
||||
import 'rxjs/add/operator/share';
|
||||
|
||||
import { CacheService } from '../cache.service';
|
||||
import { ApiService } from '../api.service';
|
||||
|
||||
export function hashCodeString(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) {
|
||||
return hash + '';
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash + '';
|
||||
}
|
||||
|
||||
// domain/feature service
|
||||
@Injectable()
|
||||
export class ModelService {
|
||||
// This is only one example of one Model depending on your domain
|
||||
constructor(public _api: ApiService, public _cache: CacheService) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* whatever domain/feature method name
|
||||
*/
|
||||
get(url) {
|
||||
// you want to return the cache if there is a response in it.
|
||||
// This would cache the first response so if your API isn't idempotent
|
||||
// you probably want to remove the item from the cache after you use it. LRU of 10
|
||||
// you can use also hashCodeString here
|
||||
let key = url;
|
||||
|
||||
if (this._cache.has(key)) {
|
||||
return Observable.of(this._cache.get(key));
|
||||
}
|
||||
// you probably shouldn't .share() and you should write the correct logic
|
||||
return this._api.get(url)
|
||||
.do(json => {
|
||||
this._cache.set(key, json);
|
||||
})
|
||||
.share();
|
||||
}
|
||||
// don't cache here since we're creating
|
||||
create() {
|
||||
// TODO
|
||||
}
|
||||
}
|
@@ -7,7 +7,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateModule } from 'ng2-translate/ng2-translate';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
import { ModelService } from './model/model.service';
|
||||
|
||||
const MODULES = [
|
||||
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
|
||||
@@ -28,7 +27,6 @@ const COMPONENTS = [
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
ModelService,
|
||||
ApiService
|
||||
];
|
||||
|
||||
|
16
src/app/store.actions.ts
Normal file
16
src/app/store.actions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type } from "./shared/ngrx/type";
|
||||
import { Action } from "@ngrx/store";
|
||||
import { AppState } from "./app.reducers";
|
||||
|
||||
export const StoreActionTypes = {
|
||||
REHYDRATE: type('dspace/ngrx/rehydrate')
|
||||
};
|
||||
|
||||
export class RehydrateStoreAction implements Action {
|
||||
type = StoreActionTypes.REHYDRATE;
|
||||
|
||||
constructor(public payload: AppState) {}
|
||||
}
|
||||
|
||||
export type StoreAction
|
||||
= RehydrateStoreAction;
|
@@ -6,6 +6,8 @@ import { fakeDataBase } from './db';
|
||||
import { fakeDemoRedisCache } from './cache';
|
||||
import { COLLECTIONS } from "./collections";
|
||||
import { ITEMS } from "./items";
|
||||
import { BUNDLES } from "./bundles";
|
||||
import { BITSTREAMS } from "./bitstreams";
|
||||
import { METADATA } from "./metadata";
|
||||
|
||||
// you would use cookies/token etc
|
||||
@@ -30,14 +32,10 @@ export function serverApi(req, res) {
|
||||
}
|
||||
|
||||
|
||||
let COLLECTION_COUNT = 2;
|
||||
let ITEM_COUNT = 2;
|
||||
|
||||
|
||||
function toJSONAPIResponse(req, data, included?) {
|
||||
function toHALResponse(req, data, included?) {
|
||||
let result = {
|
||||
"data": data,
|
||||
"links": {
|
||||
"_embedded": data,
|
||||
"_links": {
|
||||
"self": req.protocol + '://' + req.get('host') + req.originalUrl
|
||||
}
|
||||
};
|
||||
@@ -58,7 +56,7 @@ export function createMockApi() {
|
||||
console.log('GET');
|
||||
// 70ms latency
|
||||
setTimeout(function() {
|
||||
res.json(toJSONAPIResponse(req, COLLECTIONS));
|
||||
res.json(toHALResponse(req, COLLECTIONS));
|
||||
}, 0);
|
||||
|
||||
// })
|
||||
@@ -84,7 +82,7 @@ export function createMockApi() {
|
||||
try {
|
||||
req.collection_id = id;
|
||||
req.collection = COLLECTIONS.find((collection) => {
|
||||
return collection.id = id;
|
||||
return collection.id === id;
|
||||
});
|
||||
next();
|
||||
} catch (e) {
|
||||
@@ -94,8 +92,8 @@ export function createMockApi() {
|
||||
|
||||
router.route('/collections/:collection_id')
|
||||
.get(function(req, res) {
|
||||
console.log('GET', util.inspect(req.collection, { colors: true }));
|
||||
res.json(toJSONAPIResponse(req, req.collection));
|
||||
// console.log('GET', util.inspect(req.collection.id, { colors: true }));
|
||||
res.json(toHALResponse(req, req.collection));
|
||||
// })
|
||||
// .put(function(req, res) {
|
||||
// console.log('PUT', util.inspect(req.body, { colors: true }));
|
||||
@@ -120,7 +118,7 @@ export function createMockApi() {
|
||||
console.log('GET');
|
||||
// 70ms latency
|
||||
setTimeout(function() {
|
||||
res.json(toJSONAPIResponse(req, ITEMS));
|
||||
res.json(toHALResponse(req, ITEMS));
|
||||
}, 0);
|
||||
|
||||
// })
|
||||
@@ -156,12 +154,8 @@ export function createMockApi() {
|
||||
|
||||
router.route('/items/:item_id')
|
||||
.get(function(req, res) {
|
||||
console.log('GET', util.inspect(req.item, { colors: true }));
|
||||
const metadataIds: string[] = req.item.relationships.metadata.data.map(obj => obj.id);
|
||||
const itemMetadata: any[] = METADATA.filter((metadatum) => {
|
||||
return metadataIds.indexOf(metadatum.id) >= 0
|
||||
});
|
||||
res.json(toJSONAPIResponse(req, req.item, itemMetadata));
|
||||
// console.log('GET', util.inspect(req.item, { colors: true }));
|
||||
res.json(toHALResponse(req, req.item));
|
||||
// })
|
||||
// .put(function(req, res) {
|
||||
// console.log('PUT', util.inspect(req.body, { colors: true }));
|
||||
@@ -180,5 +174,64 @@ export function createMockApi() {
|
||||
// res.json(req.item);
|
||||
});
|
||||
|
||||
router.route('/bundles')
|
||||
.get(function(req, res) {
|
||||
console.log('GET');
|
||||
// 70ms latency
|
||||
setTimeout(function() {
|
||||
res.json(toHALResponse(req, BUNDLES));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
router.param('bundle_id', function(req, res, next, bundle_id) {
|
||||
// ensure correct prop type
|
||||
let id = req.params.bundle_id;
|
||||
try {
|
||||
req.bundle_id = id;
|
||||
req.bundle = BUNDLES.find((bundle) => {
|
||||
return bundle.id === id;
|
||||
});
|
||||
next();
|
||||
} catch (e) {
|
||||
next(new Error('failed to load item'));
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/bundles/:bundle_id')
|
||||
.get(function(req, res) {
|
||||
// console.log('GET', util.inspect(req.bundle, { colors: true }));
|
||||
res.json(toHALResponse(req, req.bundle));
|
||||
});
|
||||
|
||||
|
||||
router.route('/bitstreams')
|
||||
.get(function(req, res) {
|
||||
console.log('GET');
|
||||
// 70ms latency
|
||||
setTimeout(function() {
|
||||
res.json(toHALResponse(req, BITSTREAMS));
|
||||
}, 0);
|
||||
});
|
||||
|
||||
router.param('bitstream_id', function(req, res, next, bitstream_id) {
|
||||
// ensure correct prop type
|
||||
let id = req.params.bitstream_id;
|
||||
try {
|
||||
req.bitstream_id = id;
|
||||
req.bitstream = BITSTREAMS.find((bitstream) => {
|
||||
return bitstream.id === id;
|
||||
});
|
||||
next();
|
||||
} catch (e) {
|
||||
next(new Error('failed to load item'));
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/bitstreams/:bitstream_id')
|
||||
.get(function(req, res) {
|
||||
// console.log('GET', util.inspect(req.bitstream, { colors: true }));
|
||||
res.json(toHALResponse(req, req.bitstream));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
44
src/backend/bitstreams.ts
Normal file
44
src/backend/bitstreams.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const BITSTREAMS = [
|
||||
{
|
||||
"_links": {
|
||||
"self": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" },
|
||||
"bundle": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" },
|
||||
"retrieve": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa/retrieve" }
|
||||
},
|
||||
"id": "3678",
|
||||
"uuid": "43c57c2b-206f-4645-8c8f-5f10c84b09fa",
|
||||
"name": "do_open_access_CRL.pdf",
|
||||
"size": 636626,
|
||||
"checksum": {
|
||||
"value": "063dfbbbac873aa3fca479b878eccff3",
|
||||
"algorithm": "MD5"
|
||||
},
|
||||
"metadata": [
|
||||
{ "key": "dc.title", "value": "do_open_access_CRL.pdf", "language": null },
|
||||
{ "key": "dc.description", "value": "Conference Paper", "language": "en" }
|
||||
],
|
||||
"format": "Adobe PDF",
|
||||
"mimetype": "application/pdf"
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" },
|
||||
"bundle": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" },
|
||||
"retrieve": { "href": "/rest/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632/retrieve" }
|
||||
},
|
||||
"id": "8842",
|
||||
"uuid": "1a013ecc-fb25-4689-a44f-f1383ad26632",
|
||||
"name": "do_open_access_CRL.pdf.jpg",
|
||||
"size": 41183,
|
||||
"checksum": {
|
||||
"value": "a8ad475e86f9645c60e13e06f1427814",
|
||||
"algorithm": "MD5"
|
||||
},
|
||||
"metadata": [
|
||||
{ "key": "dc.title", "value": "do_open_access_CRL.pdf.jpg", "language": null },
|
||||
{ "key": "dc.description", "value": "Generated Thumbnail", "language": "en" }
|
||||
],
|
||||
"format": "JPEG",
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
];
|
38
src/backend/bundles.ts
Normal file
38
src/backend/bundles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const BUNDLES = [
|
||||
{
|
||||
"_links": {
|
||||
"self": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" },
|
||||
"items": [
|
||||
{ "href": "/items/8871" }
|
||||
],
|
||||
"bitstreams": [
|
||||
{ "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" },
|
||||
],
|
||||
"primaryBitstream": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" }
|
||||
},
|
||||
"id": "2355",
|
||||
"uuid": "35e0606d-5e18-4f9c-aa61-74fc751cc3f9",
|
||||
"name": "ORIGINAL",
|
||||
"metadata": [
|
||||
{ "key": "dc.title", "value": "ORIGINAL", "language": "en" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"self": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" },
|
||||
"items": [
|
||||
{ "href": "/items/8871" }
|
||||
],
|
||||
"bitstreams": [
|
||||
{ "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" },
|
||||
],
|
||||
"primaryBitstream": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" }
|
||||
},
|
||||
"id": "5687",
|
||||
"uuid": "a469c57a-abcf-45c3-83e4-b187ebd708fd",
|
||||
"name": "THUMBNAIL",
|
||||
"metadata": [
|
||||
{ "key": "dc.title", "value": "THUMBNAIL", "language": "en" }
|
||||
]
|
||||
}
|
||||
];
|
@@ -1,42 +1,72 @@
|
||||
export const COLLECTIONS = [
|
||||
{
|
||||
"id": "9e32a2e2-6b91-4236-a361-995ccdc14c60",
|
||||
"type": "collections",
|
||||
"attributes": {
|
||||
"name": "A Test Collection",
|
||||
"handle": "123456789/5179",
|
||||
"copyrightText": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
|
||||
"introductoryText": "<p class='lead'>An introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
|
||||
"shortDescription": "A collection for testing purposes",
|
||||
"sidebarText": "<p>Some news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>"
|
||||
"_links": {
|
||||
"self": { "href": "/collections/5179" },
|
||||
"items": [
|
||||
{ "href": "/items/8871" },
|
||||
{ "href": "/items/9978" }
|
||||
]
|
||||
},
|
||||
"relationships": {
|
||||
"items": {
|
||||
"data": [
|
||||
{ "type": "items", "id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78" },
|
||||
{ "type": "items", "id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5" }
|
||||
]
|
||||
"id": "5179",
|
||||
"uuid": "9e32a2e2-6b91-4236-a361-995ccdc14c60",
|
||||
"name": "A Test Collection",
|
||||
"handle": "123456789/5179",
|
||||
"metadata": [
|
||||
{
|
||||
"key": "dc.rights",
|
||||
"value": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description",
|
||||
"value": "<p class='lead'>An introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.abstract",
|
||||
"value": "A collection for testing purposes",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.tableofcontents",
|
||||
"value": "<p>Some news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>",
|
||||
"language": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "598ce822-c357-46f3-ab70-63724d02d6ad",
|
||||
"type": "collections",
|
||||
"attributes": {
|
||||
"name": "Another Test Collection",
|
||||
"handle": "123456789/6547",
|
||||
"copyrightText": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
|
||||
"introductoryText": "<p class='lead'>Another introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
|
||||
"shortDescription": "Another collection for testing purposes",
|
||||
"sidebarText": "<p>Some more news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>"
|
||||
"_links": {
|
||||
"self": { "href": "/collections/6547" },
|
||||
"items": [
|
||||
{ "href": "/items/8871" },
|
||||
{ "href": "/items/9978" }
|
||||
]
|
||||
},
|
||||
"relationships": {
|
||||
"items": {
|
||||
"data": [
|
||||
{ "type": "items", "id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78" },
|
||||
{ "type": "items", "id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5" }
|
||||
]
|
||||
"id": "6547",
|
||||
"uuid": "598ce822-c357-46f3-ab70-63724d02d6ad",
|
||||
"name": "Another Test Collection",
|
||||
"handle": "123456789/6547",
|
||||
"metadata": [
|
||||
{
|
||||
"key": "dc.rights",
|
||||
"value": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description",
|
||||
"value": "<p class='lead'>Another introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.abstract",
|
||||
"value": "Another collection for testing purposes",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.tableofcontents",
|
||||
"value": "<p>Some more news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>",
|
||||
"language": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
@@ -1,128 +1,166 @@
|
||||
export const ITEMS = [
|
||||
{
|
||||
"id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78",
|
||||
"type": "items",
|
||||
"attributes": {
|
||||
"name": "Do Open-Access Articles Have a Greater Research Impact?",
|
||||
"handle": "123456789/8871",
|
||||
"lastModified": "2016-10-14 10:41:12.886",
|
||||
"isArchived": true,
|
||||
"isWithdrawn": false
|
||||
},
|
||||
"relationships": {
|
||||
"collections": {
|
||||
"data": [
|
||||
{ "type": "collections", "id": "9e32a2e2-6b91-4236-a361-995ccdc14c60" },
|
||||
{ "type": "collections", "id": "598ce822-c357-46f3-ab70-63724d02d6ad" }
|
||||
]
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/items/8871"
|
||||
},
|
||||
"metadata": {
|
||||
"data": [
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "d58a3098-b390-4cd6-8f52-b088b3daa637",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "56660730-0e0d-47ec-864a-bda2327d5716",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "b9d4ae74-2758-4964-a95e-eecd35b62f26",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "311529ea-e339-4d8f-9292-813ebe515f03",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "fa875444-3faf-482a-b099-77233bda914d",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "ddbb161b-6e52-4a90-9096-c8eae8cec4c9",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "ba51287d-a2c9-409b-8129-060b693a7570",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "e5c1c9d4-b4e2-4bdc-9153-6b769742b33f",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "4c125844-1eca-47aa-98f8-61c51a9c962f",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "362c753c-a44d-468d-b256-486470b8c1e1",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": " 69a02355-37bb-479f-9496-c8743fcacf3c",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "ffbd75d5-bf3a-47ff-af22-490240f6fcc6",
|
||||
}
|
||||
]
|
||||
}
|
||||
"collections": [
|
||||
{
|
||||
"href": "/collections/5179"
|
||||
},
|
||||
{
|
||||
"href": "/collections/6547"
|
||||
}
|
||||
],
|
||||
"bundles": [
|
||||
{
|
||||
"href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9"
|
||||
},
|
||||
{
|
||||
"href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "8871",
|
||||
"uuid": "21539b1d-9ef1-4eda-9c77-49565b5bfb78",
|
||||
"name": "Do Open-Access Articles Have a Greater Research Impact?",
|
||||
"handle": "123456789/8871",
|
||||
"lastModified": "2016-10-14 10:41:12.886",
|
||||
"isArchived": true,
|
||||
"isWithdrawn": false,
|
||||
"metadata": [
|
||||
{
|
||||
"key": "dc.contributor.author",
|
||||
"value": "Antelman, Kristin",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.date.accessioned",
|
||||
"value": "2016-10-14T10:41:13Z",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.date.available",
|
||||
"value": "2016-10-14T10:41:13Z",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.date.issued",
|
||||
"value": "2004-09-01",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.identifier.uri",
|
||||
"value": "http://hdl.handle.net/123456789/8871",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.abstract",
|
||||
"value": "Although many authors believe that their work has a greater research impact if it is freely available, studies to demonstrate that impact are few. This study looks at articles in four disciplines at varying stages of adoption of open access—philosophy, political science, electrical and electronic engineering and mathematics—to see whether they have a greater impact as measured by citations in the ISI Web of Science database when their authors make them freely available on the Internet. The finding is that, across all four disciplines, freely available articles do have a greater research impact. Shedding light on this category of open access reveals that scholars in diverse disciplines are adopting open-access practices and being rewarded for it.",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.publisher",
|
||||
"value": "College & Research Libraries News",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.subject",
|
||||
"value": "Publishing",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.subject",
|
||||
"value": "Intellectual Property",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.subject",
|
||||
"value": "Open Access",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.title",
|
||||
"value": "Do Open-Access Articles Have a Greater Research Impact?",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.type",
|
||||
"value": "(not specified)",
|
||||
"language": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5",
|
||||
"type": "items",
|
||||
"attributes": {
|
||||
"name": "Another Test Item",
|
||||
"handle": "123456789/9978",
|
||||
"lastModified": "2016-05-27 03:00:20.063",
|
||||
"isArchived": true,
|
||||
"isWithdrawn": false
|
||||
},
|
||||
"relationships": {
|
||||
"collections": {
|
||||
"data": [
|
||||
{ "type": "collections", "id": "9e32a2e2-6b91-4236-a361-995ccdc14c60" },
|
||||
{ "type": "collections", "id": "598ce822-c357-46f3-ab70-63724d02d6ad" }
|
||||
]
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/items/9978"
|
||||
},
|
||||
"metadata": {
|
||||
"data": [
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "981c725e-53f3-4749-89ee-ef042f23c3c3",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "521df61d-c541-4180-beb8-ac0a1bd1e852",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "551a216d-5350-4b15-9398-9bc2e95e7a3d",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": " eb17dce4-3892-47fe-b014-6ff8e17a93ef",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "3e840957-cb1b-4521-8f5d-fb5f6956f303",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "ae0bc880-481b-4425-aa5b-354b38d24e4f",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "8dc89ac4-d606-4f1a-8524-8f70a6b371de",
|
||||
},
|
||||
{
|
||||
"type": "metadata",
|
||||
"id": "13185eb9-dc05-4bd7-9c2d-5322a2ac5326",
|
||||
}
|
||||
]
|
||||
"collections": [
|
||||
{
|
||||
"href": "/collections/5179"
|
||||
},
|
||||
{
|
||||
"href": "/collections/6547"
|
||||
}
|
||||
],
|
||||
"bundles": [
|
||||
{
|
||||
"href": "/bundles/b0176baa-d52e-4c20-a8e6-d586f2c70c76"
|
||||
},
|
||||
{
|
||||
"href": "/bundles/40b1cd3f-07ad-4ca6-9716-132671f93a15"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "9978",
|
||||
"uuid": "be8325f7-243b-49f4-8a4b-df2b793ff3b5",
|
||||
"name": "Another Test Item",
|
||||
"handle": "123456789/9978",
|
||||
"lastModified": "2016-05-27 03:00:20.063",
|
||||
"isArchived": true,
|
||||
"isWithdrawn": false,
|
||||
"metadata": [
|
||||
{
|
||||
"key": "dc.contributor.author",
|
||||
"value": "John Doe",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.date.accessioned",
|
||||
"value": "2016-05-27T07:45:04Z",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.date.available",
|
||||
"value": "2016-05-27T07:45:04Z",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.date.issued",
|
||||
"value": "2016-05-27",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.identifier.uri",
|
||||
"value": "http://hdl.handle.net/123456789/9978",
|
||||
"language": null
|
||||
},
|
||||
{
|
||||
"key": "dc.description.abstract",
|
||||
"value": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas lacus velit, lacinia eu ultrices nec, auctor in sem. Donec interdum convallis ornare. Aliquam et tortor risus. Praesent ut feugiat eros, eu consequat nibh. Morbi id quam eu mi pellentesque consequat vel vitae sem. Praesent sed velit ullamcorper, efficitur odio non, aliquet urna. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque eu placerat urna. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla non aliquet mauris. Nulla quis posuere lorem. Pellentesque tempus maximus ipsum ac pretium. Nunc hendrerit tempus sem, vitae luctus erat consectetur vestibulum. Nulla sodales felis in dictum sagittis.\n\nNullam porta magna quis magna vulputate elementum. Pellentesque dictum lorem id nisl tincidunt condimentum. Sed est dolor, dapibus sit amet augue at, malesuada cursus quam. Pellentesque elit felis, malesuada dictum congue tristique, iaculis euismod ligula. Donec dignissim dolor eu lacus pulvinar porttitor. Sed quis semper augue, dictum sollicitudin eros. \n\nMauris congue lectus at turpis viverra scelerisque. Praesent at urna rhoncus, condimentum odio ac, sagittis libero. Nulla aliquam ornare bibendum. Duis quis ornare urna. Suspendisse semper tincidunt neque nec consequat. Sed enim diam, mollis eu neque vitae, lacinia varius risus. Fusce nec sem tempor, efficitur lectus sed, porta sem. Pellentesque sollicitudin ut dui vitae malesuada.",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.title",
|
||||
"value": "Another Test Item",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"key": "dc.type",
|
||||
"value": "(not specified)",
|
||||
"language": "en"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
|
||||
import { Http } from '@angular/http';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { UniversalModule, isBrowser, isNode, AUTO_PREBOOT } from 'angular2-universal/browser'; // for AoT we need to manually split universal packages
|
||||
import { UniversalModule, isBrowser, isNode } from 'angular2-universal/browser'; // for AoT we need to manually split universal packages
|
||||
import { IdlePreload, IdlePreloadModule } from '@angularclass/idle-preload';
|
||||
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
@@ -10,12 +10,19 @@ import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-tra
|
||||
|
||||
import { AppModule, AppComponent } from './app/app.module';
|
||||
import { SharedModule } from './app/shared/shared.module';
|
||||
import { CacheService } from './app/shared/cache.service';
|
||||
import { CoreModule } from "./app/core/core.module";
|
||||
|
||||
import { StoreModule, Store } from "@ngrx/store";
|
||||
import { RouterStoreModule } from "@ngrx/router-store";
|
||||
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
|
||||
import { rootReducer, NGRX_CACHE_KEY, AppState } from './app/app.reducers';
|
||||
import { effects } from './app/app.effects';
|
||||
|
||||
// Will be merged into @angular/platform-browser in a later release
|
||||
// see https://github.com/angular/angular/pull/12322
|
||||
import { Meta } from './angular2-meta';
|
||||
import { RehydrateStoreAction } from "./app/store.actions";
|
||||
import { GlobalConfig } from "./config";
|
||||
|
||||
// import * as LRU from 'modern-lru';
|
||||
|
||||
@@ -38,7 +45,6 @@ export function getResponse() {
|
||||
}
|
||||
|
||||
|
||||
// TODO(gdi2290): refactor into Universal
|
||||
export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
|
||||
@NgModule({
|
||||
@@ -60,6 +66,10 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
CoreModule.forRoot(),
|
||||
SharedModule,
|
||||
AppModule,
|
||||
StoreModule.provideStore(rootReducer),
|
||||
RouterStoreModule.connectRouter(),
|
||||
StoreDevtoolsModule.instrumentOnlyWithExtension(),
|
||||
effects
|
||||
],
|
||||
providers: [
|
||||
{ provide: 'isBrowser', useValue: isBrowser },
|
||||
@@ -70,23 +80,23 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
|
||||
{ provide: 'LRU', useFactory: getLRU, deps: [] },
|
||||
|
||||
CacheService,
|
||||
|
||||
Meta,
|
||||
|
||||
// { provide: AUTO_PREBOOT, useValue: false } // turn off auto preboot complete
|
||||
]
|
||||
})
|
||||
export class MainModule {
|
||||
constructor(public cache: CacheService) {
|
||||
constructor(public store: Store<AppState>) {
|
||||
// TODO(gdi2290): refactor into a lifecycle hook
|
||||
this.doRehydrate();
|
||||
}
|
||||
|
||||
doRehydrate() {
|
||||
let defaultValue = {};
|
||||
let serverCache = this._getCacheValue(CacheService.KEY, defaultValue);
|
||||
this.cache.rehydrate(serverCache);
|
||||
if (GlobalConfig.universal.shouldRehydrate) {
|
||||
let defaultValue = {};
|
||||
let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue);
|
||||
this.store.dispatch(new RehydrateStoreAction(serverCache));
|
||||
}
|
||||
}
|
||||
|
||||
_getCacheValue(key: string, defaultValue: any): any {
|
||||
@@ -95,7 +105,7 @@ export class MainModule {
|
||||
if (win[UNIVERSAL_KEY] && win[UNIVERSAL_KEY][key]) {
|
||||
let serverCache = defaultValue;
|
||||
try {
|
||||
serverCache = JSON.parse(win[UNIVERSAL_KEY][key]);
|
||||
serverCache = win[UNIVERSAL_KEY][key];
|
||||
if (typeof serverCache !== typeof defaultValue) {
|
||||
console.log('Angular Universal: The type of data from the server is different from the default value type');
|
||||
serverCache = defaultValue;
|
||||
|
@@ -9,9 +9,14 @@ import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-tra
|
||||
|
||||
import { AppModule, AppComponent } from './app/app.module';
|
||||
import { SharedModule } from './app/shared/shared.module';
|
||||
import { CacheService } from './app/shared/cache.service';
|
||||
import { CoreModule } from "./app/core/core.module";
|
||||
|
||||
import { StoreModule, Store } from "@ngrx/store";
|
||||
import { RouterStoreModule } from "@ngrx/router-store";
|
||||
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
|
||||
import { rootReducer, AppState, NGRX_CACHE_KEY } from './app/app.reducers';
|
||||
import { effects } from './app/app.effects';
|
||||
|
||||
// Will be merged into @angular/platform-browser in a later release
|
||||
// see https://github.com/angular/angular/pull/12322
|
||||
import { Meta } from './angular2-meta';
|
||||
@@ -30,7 +35,6 @@ export function getResponse() {
|
||||
return Zone.current.get('res') || {};
|
||||
}
|
||||
|
||||
// TODO(gdi2290): refactor into Universal
|
||||
export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
|
||||
@NgModule({
|
||||
@@ -51,6 +55,10 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
CoreModule.forRoot(),
|
||||
SharedModule,
|
||||
AppModule,
|
||||
StoreModule.provideStore(rootReducer),
|
||||
RouterStoreModule.connectRouter(),
|
||||
StoreDevtoolsModule.instrumentOnlyWithExtension(),
|
||||
effects
|
||||
],
|
||||
providers: [
|
||||
{ provide: 'isBrowser', useValue: isBrowser },
|
||||
@@ -61,13 +69,11 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
|
||||
|
||||
{ provide: 'LRU', useFactory: getLRU, deps: [] },
|
||||
|
||||
CacheService,
|
||||
|
||||
Meta,
|
||||
]
|
||||
})
|
||||
export class MainModule {
|
||||
constructor(public cache: CacheService) {
|
||||
constructor(public store: Store<AppState>) {
|
||||
|
||||
}
|
||||
|
||||
@@ -76,14 +82,17 @@ export class MainModule {
|
||||
* in Universal for now until it's fixed
|
||||
*/
|
||||
universalDoDehydrate = (universalCache) => {
|
||||
universalCache[CacheService.KEY] = JSON.stringify(this.cache.dehydrate());
|
||||
}
|
||||
this.store.take(1).subscribe(state => {
|
||||
universalCache[NGRX_CACHE_KEY] = state;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the cache after it's rendered
|
||||
*/
|
||||
universalAfterDehydrate = () => {
|
||||
// comment out if LRU provided at platform level to be shared between each user
|
||||
this.cache.clear();
|
||||
// this.cache.clear();
|
||||
//TODO is this necessary in dspace's case?
|
||||
}
|
||||
}
|
||||
|
@@ -5555,6 +5555,10 @@ ts-helpers@1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ts-helpers/-/ts-helpers-1.1.2.tgz#fc69be9f1f3baed01fb1a0ef8d4cfe748814d835"
|
||||
|
||||
ts-md5@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ts-md5/-/ts-md5-1.2.0.tgz#c30b385261bd27962f754509675f0e9578810569"
|
||||
|
||||
ts-node@1.7.2:
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-1.7.2.tgz#d67bbc5c48fde16c244debbfe81b020587369a02"
|
||||
|
Reference in New Issue
Block a user