From c8fb98760d0a80798be389989f12ab43a5450695 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 14 Feb 2017 14:40:57 +0100 Subject: [PATCH 01/21] Added mocks for all model types, added CollectionDataService --- src/app/app.effects.ts | 2 + src/app/app.reducers.ts | 3 + src/app/core/core.effects.ts | 6 + src/app/core/core.module.ts | 4 + src/app/core/core.reducers.ts | 17 ++ .../collection/collection-data.effects.ts | 47 +++ .../collection/collection-data.reducer.ts | 17 ++ .../collection/collection-data.service.ts | 30 ++ .../collection-find-multiple.actions.ts | 55 ++++ .../collection-find-multiple.reducer.ts | 60 ++++ .../collection-find-single.actions.ts | 42 +++ .../collection-find-single.reducer.ts | 52 ++++ .../dspace-rest-v2/dspace-rest-v2.service.ts | 34 +++ src/app/core/shared/bitstream.model.ts | 37 +++ src/app/core/shared/bundle.model.ts | 22 ++ src/app/core/shared/collection.model.ts | 69 +++++ src/app/core/shared/dspace-object.model.ts | 67 +++++ src/app/core/shared/item.model.ts | 38 +++ src/app/core/shared/metadatum.model.ts | 21 ++ .../core/shared/pagination-options.model.ts | 12 + src/app/core/shared/sort-options.model.ts | 9 + .../core/url-combiner/rest-url-combiner.ts | 14 + src/app/core/url-combiner/ui-url-combiner.ts | 14 + .../core/url-combiner/url-combiner.spec.ts | 33 +++ src/app/core/url-combiner/url-combiner.ts | 55 ++++ src/backend/api.ts | 89 +++++- src/backend/bitstreams.ts | 42 +++ src/backend/bundles.ts | 36 +++ src/backend/collections.ts | 92 +++--- src/backend/items.ts | 272 ++++++++++-------- 30 files changed, 1129 insertions(+), 162 deletions(-) create mode 100644 src/app/core/core.effects.ts create mode 100644 src/app/core/core.reducers.ts create mode 100644 src/app/core/data-services/collection/collection-data.effects.ts create mode 100644 src/app/core/data-services/collection/collection-data.reducer.ts create mode 100644 src/app/core/data-services/collection/collection-data.service.ts create mode 100644 src/app/core/data-services/collection/collection-find-multiple.actions.ts create mode 100644 src/app/core/data-services/collection/collection-find-multiple.reducer.ts create mode 100644 src/app/core/data-services/collection/collection-find-single.actions.ts create mode 100644 src/app/core/data-services/collection/collection-find-single.reducer.ts create mode 100644 src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts create mode 100644 src/app/core/shared/bitstream.model.ts create mode 100644 src/app/core/shared/bundle.model.ts create mode 100644 src/app/core/shared/collection.model.ts create mode 100644 src/app/core/shared/dspace-object.model.ts create mode 100644 src/app/core/shared/item.model.ts create mode 100644 src/app/core/shared/metadatum.model.ts create mode 100644 src/app/core/shared/pagination-options.model.ts create mode 100644 src/app/core/shared/sort-options.model.ts create mode 100644 src/app/core/url-combiner/rest-url-combiner.ts create mode 100644 src/app/core/url-combiner/ui-url-combiner.ts create mode 100644 src/app/core/url-combiner/url-combiner.spec.ts create mode 100644 src/app/core/url-combiner/url-combiner.ts create mode 100644 src/backend/bitstreams.ts create mode 100644 src/backend/bundles.ts diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts index 6f149709f8..a82b936ff5 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -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) ]; diff --git a/src/app/app.reducers.ts b/src/app/app.reducers.ts index deb75dd06a..db27ecf9a7 100644 --- a/src/app/app.reducers.ts +++ b/src/app/app.reducers.ts @@ -2,14 +2,17 @@ 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"; export interface AppState { + core: CoreState; router: RouterState; hostWindow: HostWindowState; header: HeaderState; } export const reducers = { + core: coreReducer, router: routerReducer, hostWindow: hostWindowReducer, header: headerReducer diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts new file mode 100644 index 0000000000..b2508bc25a --- /dev/null +++ b/src/app/core/core.effects.ts @@ -0,0 +1,6 @@ +import { EffectsModule } from "@ngrx/effects"; +import { CollectionDataEffects } from "./data-services/collection/collection-data.effects"; + +export const coreEffects = [ + EffectsModule.run(CollectionDataEffects) +]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 3a688f53bd..7fece0601a 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -2,6 +2,8 @@ import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core import { CommonModule } from '@angular/common'; import { SharedModule } from "../shared/shared.module"; import { isNotEmpty } from "../shared/empty.util"; +import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; +import { CollectionDataService } from "./data-services/collection/collection-data.service"; const IMPORTS = [ CommonModule, @@ -15,6 +17,8 @@ const EXPORTS = [ ]; const PROVIDERS = [ + CollectionDataService, + DSpaceRESTv2Service ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts new file mode 100644 index 0000000000..47be0d13f3 --- /dev/null +++ b/src/app/core/core.reducers.ts @@ -0,0 +1,17 @@ +import { combineReducers } from "@ngrx/store"; +import { + CollectionDataState, + collectionDataReducer +} from "./data-services/collection/collection-data.reducer"; + +export interface CoreState { + collectionData: CollectionDataState +} + +export const reducers = { + collectionData: collectionDataReducer, +}; + +export function coreReducer(state: any, action: any) { + return combineReducers(reducers)(state, action); +} diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts new file mode 100644 index 0000000000..7a5fdd4f6b --- /dev/null +++ b/src/app/core/data-services/collection/collection-data.effects.ts @@ -0,0 +1,47 @@ +import { Injectable } from "@angular/core"; +import { Actions, Effect } from "@ngrx/effects"; +import { Collection } from "../../shared/collection.model"; +import { Observable } from "rxjs"; +import { + CollectionFindMultipleActionTypes, + CollectionFindMultipleSuccessAction, + CollectionFindMultipleErrorAction +} from "./collection-find-multiple.actions"; +import { + CollectionFindSingleActionTypes, + CollectionFindByIdSuccessAction, + CollectionFindByIdErrorAction +} from "./collection-find-single.actions"; +import { DSpaceRESTV2Response } from "../../dspace-rest-v2/dspace-rest-v2-response.model"; +import { DSpaceRESTv2Serializer } from "../../dspace-rest-v2/dspace-rest-v2.serializer"; +import { DSpaceRESTv2Service } from "../../dspace-rest-v2/dspace-rest-v2.service"; + + +@Injectable() +export class CollectionDataEffects { + constructor( + private actions$: Actions, + private restApiService: DSpaceRESTv2Service + ) {} + + @Effect() findAll$ = this.actions$ + .ofType(CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST) + .switchMap(() => { + return this.restApiService.get('/collections') + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserializeArray(data)) + .map((collections: Collection[]) => new CollectionFindMultipleSuccessAction(collections)) + .catch((errorMsg: string) => Observable.of(new CollectionFindMultipleErrorAction(errorMsg))); + }); + + @Effect() findById$ = this.actions$ + .ofType(CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST) + .switchMap(action => { + return this.restApiService.get(`/collections/${action.payload}`) + .map((data: DSpaceRESTV2Response) => { + const t = new DSpaceRESTv2Serializer(Collection).deserialize(data); + return t; + }) + .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection)) + .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); + }); +} diff --git a/src/app/core/data-services/collection/collection-data.reducer.ts b/src/app/core/data-services/collection/collection-data.reducer.ts new file mode 100644 index 0000000000..bb9adc95cc --- /dev/null +++ b/src/app/core/data-services/collection/collection-data.reducer.ts @@ -0,0 +1,17 @@ +import { combineReducers } from "@ngrx/store"; +import { CollectionFindMultipleState, findMultipleReducer } from "./collection-find-multiple.reducer"; +import { CollectionFindSingleState, findSingleReducer } from "./collection-find-single.reducer"; + +export interface CollectionDataState { + findMultiple: CollectionFindMultipleState, + findSingle: CollectionFindSingleState +} + +const reducers = { + findMultiple: findMultipleReducer, + findSingle: findSingleReducer +}; + +export function collectionDataReducer(state: any, action: any) { + return combineReducers(reducers)(state, action); +} diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts new file mode 100644 index 0000000000..b774b5cd0d --- /dev/null +++ b/src/app/core/data-services/collection/collection-data.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { CollectionDataState } from "./collection-data.reducer"; +import { Store } from "@ngrx/store"; +import { Collection } from "../../shared/collection.model"; +import { CollectionFindMultipleRequestAction } from "./collection-find-multiple.actions"; +import { CollectionFindByIdRequestAction } from "./collection-find-single.actions"; +import { isNotEmpty } from "../../../shared/empty.util"; +import 'rxjs/add/operator/filter'; + +@Injectable() +export class CollectionDataService { + constructor( + private store: Store + ) { } + + findAll(scope?: Collection): Observable { + this.store.dispatch(new CollectionFindMultipleRequestAction(scope)); + return this.store.select('core', 'collectionData', 'findMultiple', 'collections'); + } + + findById(id: string): Observable { + this.store.dispatch(new CollectionFindByIdRequestAction(id)); + return this.store.select('core', 'collectionData', 'findSingle', 'collection') + //this filter is necessary because the same collection + //object in the state is used for every findById call + .filter(collection => isNotEmpty(collection) && collection.id === id); + } + +} diff --git a/src/app/core/data-services/collection/collection-find-multiple.actions.ts b/src/app/core/data-services/collection/collection-find-multiple.actions.ts new file mode 100644 index 0000000000..95009602ec --- /dev/null +++ b/src/app/core/data-services/collection/collection-find-multiple.actions.ts @@ -0,0 +1,55 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../../shared/ngrx/type"; +import { Collection } from "../../shared/collection.model"; +import { PaginationOptions } from "../../shared/pagination-options.model"; +import { SortOptions } from "../../shared/sort-options.model"; + +export const CollectionFindMultipleActionTypes = { + FIND_MULTI_REQUEST: type('dspace/core/data/collection/FIND_MULTI_REQUEST'), + FIND_MULTI_SUCCESS: type('dspace/core/data/collection/FIND_MULTI_SUCCESS'), + FIND_MULTI_ERROR: type('dspace/core/data/collection/FIND_MULTI_ERROR') +}; + +export class CollectionFindMultipleRequestAction implements Action { + type = CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST; + payload: { + scope: Collection, + paginationOptions: PaginationOptions, + sortOptions: SortOptions + }; + + constructor( + scope?: Collection, + paginationOptions: PaginationOptions = new PaginationOptions(), + sortOptions: SortOptions = new SortOptions() + ) { + this.payload = { + scope, + paginationOptions, + sortOptions + } + } +} + +export class CollectionFindMultipleSuccessAction implements Action { + type = CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS; + payload: Collection[]; + + constructor(collections: Collection[]) { + this.payload = collections; + } +} + +export class CollectionFindMultipleErrorAction implements Action { + type = CollectionFindMultipleActionTypes.FIND_MULTI_ERROR; + payload: string; + + constructor(errorMessage: string) { + this.payload = errorMessage; + } +} + +export type CollectionFindMultipleAction + = CollectionFindMultipleRequestAction + | CollectionFindMultipleSuccessAction + | CollectionFindMultipleErrorAction; diff --git a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts new file mode 100644 index 0000000000..9b4934317c --- /dev/null +++ b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts @@ -0,0 +1,60 @@ +import { Collection } from "../../shared/collection.model"; +import { PaginationOptions } from "../../shared/pagination-options.model"; +import { SortOptions } from "../../shared/sort-options.model"; +import { + CollectionFindMultipleAction, + CollectionFindMultipleActionTypes +} from "./collection-find-multiple.actions"; + +export interface CollectionFindMultipleState { + scope: Collection; + collections: Collection[]; + isLoading: boolean; + errorMessage: string; + paginationOptions: PaginationOptions; + sortOptions: SortOptions; +} + +const initialState: CollectionFindMultipleState = { + scope: undefined, + collections: [], + isLoading: false, + errorMessage: undefined, + paginationOptions: undefined, + sortOptions: undefined +}; + +export const findMultipleReducer = (state = initialState, action: CollectionFindMultipleAction): CollectionFindMultipleState => { + switch (action.type) { + + case CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST: { + return Object.assign({}, state, { + scope: action.payload.scope, + collections: [], + isLoading: true, + errorMessage: undefined, + paginationOptions: action.payload.paginationOptions, + sortOptions: action.payload.sortOptions + }); + } + + case CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS: { + return Object.assign({}, state, { + isLoading: false, + collections: action.payload, + errorMessage: undefined + }); + } + + case CollectionFindMultipleActionTypes.FIND_MULTI_ERROR: { + return Object.assign({}, state, { + isLoading: false, + errorMessage: action.payload + }); + } + + default: { + return state; + } + } +}; diff --git a/src/app/core/data-services/collection/collection-find-single.actions.ts b/src/app/core/data-services/collection/collection-find-single.actions.ts new file mode 100644 index 0000000000..37ccc8c328 --- /dev/null +++ b/src/app/core/data-services/collection/collection-find-single.actions.ts @@ -0,0 +1,42 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../../shared/ngrx/type"; +import { Collection } from "../../shared/collection.model"; + +export const CollectionFindSingleActionTypes = { + FIND_BY_ID_REQUEST: type('dspace/core/data/collection/FIND_BY_ID_REQUEST'), + FIND_BY_ID_SUCCESS: type('dspace/core/data/collection/FIND_BY_ID_SUCCESS'), + FIND_BY_ID_ERROR: type('dspace/core/data/collection/FIND_BY_ID_ERROR') +}; + +export class CollectionFindByIdRequestAction implements Action { + type = CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST; + payload: string; + + constructor(id: string) { + this.payload = id; + } +} + +export class CollectionFindByIdSuccessAction implements Action { + type = CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS; + payload: Collection; + + constructor(collection: Collection) { + this.payload = collection; + } +} + +export class CollectionFindByIdErrorAction implements Action { + type = CollectionFindSingleActionTypes.FIND_BY_ID_ERROR; + payload: string; + + constructor(errorMessage: string) { + this.payload = errorMessage; + } +} + +export type CollectionFindSingleAction + = CollectionFindByIdRequestAction + | CollectionFindByIdSuccessAction + | CollectionFindByIdErrorAction; + diff --git a/src/app/core/data-services/collection/collection-find-single.reducer.ts b/src/app/core/data-services/collection/collection-find-single.reducer.ts new file mode 100644 index 0000000000..0fc0bf65cf --- /dev/null +++ b/src/app/core/data-services/collection/collection-find-single.reducer.ts @@ -0,0 +1,52 @@ +import { Collection } from "../../shared/collection.model"; +import { + CollectionFindSingleAction, + CollectionFindSingleActionTypes +} from "./collection-find-single.actions"; + +export interface CollectionFindSingleState { + collection: Collection; + isLoading: boolean; + errorMessage: string; + id: string; +} + +const initialState: CollectionFindSingleState = { + collection: undefined, + isLoading: false, + errorMessage: undefined, + id: undefined, +}; + +export const findSingleReducer = (state = initialState, action: CollectionFindSingleAction): CollectionFindSingleState => { + switch (action.type) { + + case CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST: { + return Object.assign({}, state, { + isLoading: true, + id: action.payload, + collections: undefined, + errorMessage: undefined, + }); + } + + case CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS: { + return Object.assign({}, state, { + isLoading: false, + collection: action.payload, + errorMessage: undefined + }); + } + + case CollectionFindSingleActionTypes.FIND_BY_ID_ERROR: { + return Object.assign({}, state, { + isLoading: false, + errorMessage: action.payload + }); + } + + default: { + return state; + } + } +}; diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts new file mode 100644 index 0000000000..e4261ba9f2 --- /dev/null +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -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} + * An Observablse containing the response from the server + */ + get(relativeURL: string, options?: RequestOptionsArgs): Observable { + return this._http.get(new RESTURLCombiner(relativeURL).toString(), options) + .map(res => res.json()) + .catch(err => { + console.log('Error: ', err); + return Observable.throw(err); + }); + } + +} diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts new file mode 100644 index 0000000000..43217a1292 --- /dev/null +++ b/src/app/core/shared/bitstream.model.ts @@ -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; + + /** + * The Bundle that owns this Bitstream + */ + owner: Bundle; +} diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts new file mode 100644 index 0000000000..3e480bbb09 --- /dev/null +++ b/src/app/core/shared/bundle.model.ts @@ -0,0 +1,22 @@ +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; + + /** + * The Item that owns this Bundle + */ + owner: Item; +} diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts new file mode 100644 index 0000000000..7048ded4a4 --- /dev/null +++ b/src/app/core/shared/collection.model.ts @@ -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; + + /** + * The Collection that owns this Collection + */ + owner: Collection; + +} diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts new file mode 100644 index 0000000000..ecb449e709 --- /dev/null +++ b/src/app/core/shared/dspace-object.model.ts @@ -0,0 +1,67 @@ +import { autoserialize, autoserializeAs } from "cerialize"; +import { Metadatum } from "./metadatum.model" +import { isEmpty, isNotEmpty } from "../../shared/empty.util"; + +/** + * An abstract model class for a DSpaceObject. + */ +export abstract class DSpaceObject { + + /** + * The identifier of this DSpaceObject + */ + @autoserialize + id: 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; + + /** + * An array of DSpaceObjects that are direct parents of this DSpaceObject + */ + parents: Array; + + /** + * 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; + } + } +} diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts new file mode 100644 index 0000000000..c70d88874d --- /dev/null +++ b/src/app/core/shared/item.model.ts @@ -0,0 +1,38 @@ +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; + + /** + * The Collection that owns this Item + */ + owner: Collection; +} diff --git a/src/app/core/shared/metadatum.model.ts b/src/app/core/shared/metadatum.model.ts new file mode 100644 index 0000000000..f738017583 --- /dev/null +++ b/src/app/core/shared/metadatum.model.ts @@ -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; +} diff --git a/src/app/core/shared/pagination-options.model.ts b/src/app/core/shared/pagination-options.model.ts new file mode 100644 index 0000000000..3ab67e1e79 --- /dev/null +++ b/src/app/core/shared/pagination-options.model.ts @@ -0,0 +1,12 @@ +export class PaginationOptions { + /** + * The number of results per page. + */ + resultsPerPage: number = 10; + + /** + * The active page. + */ + currentPage: number = 1; + +} diff --git a/src/app/core/shared/sort-options.model.ts b/src/app/core/shared/sort-options.model.ts new file mode 100644 index 0000000000..dff65c3d35 --- /dev/null +++ b/src/app/core/shared/sort-options.model.ts @@ -0,0 +1,9 @@ +export enum SortDirection { + Ascending, + Descending +} + +export class SortOptions { + field: string = "id"; + direction: SortDirection = SortDirection.Ascending +} diff --git a/src/app/core/url-combiner/rest-url-combiner.ts b/src/app/core/url-combiner/rest-url-combiner.ts new file mode 100644 index 0000000000..c1da441631 --- /dev/null +++ b/src/app/core/url-combiner/rest-url-combiner.ts @@ -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) { + super(GlobalConfig.rest.baseURL, GlobalConfig.rest.nameSpace, ...parts); + } +} diff --git a/src/app/core/url-combiner/ui-url-combiner.ts b/src/app/core/url-combiner/ui-url-combiner.ts new file mode 100644 index 0000000000..260d33d1ca --- /dev/null +++ b/src/app/core/url-combiner/ui-url-combiner.ts @@ -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) { + super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts); + } +} diff --git a/src/app/core/url-combiner/url-combiner.spec.ts b/src/app/core/url-combiner/url-combiner.spec.ts new file mode 100644 index 0000000000..eb94d3a20f --- /dev/null +++ b/src/app/core/url-combiner/url-combiner.spec.ts @@ -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(''); + }); + +}); diff --git a/src/app/core/url-combiner/url-combiner.ts b/src/app/core/url-combiner/url-combiner.ts new file mode 100644 index 0000000000..dcbc14ae6f --- /dev/null +++ b/src/app/core/url-combiner/url-combiner.ts @@ -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; + + /** + * Creates a new URLCombiner + * + * @param parts + * a variable number of strings representing parts of a URL + */ + constructor(...parts:Array) { + // 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; + } + } + +} diff --git a/src/backend/api.ts b/src/backend/api.ts index 055b4f4d3d..edf7cc3f20 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -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) { @@ -95,7 +93,7 @@ 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)); + 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); // }) @@ -161,7 +159,7 @@ export function createMockApi() { const itemMetadata: any[] = METADATA.filter((metadatum) => { return metadataIds.indexOf(metadatum.id) >= 0 }); - res.json(toJSONAPIResponse(req, req.item, itemMetadata)); + res.json(toHALResponse(req, req.item, itemMetadata)); // }) // .put(function(req, res) { // console.log('PUT', util.inspect(req.body, { colors: true })); @@ -180,5 +178,72 @@ 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 })); + const metadataIds: string[] = req.bundle.relationships.metadata.data.map(obj => obj.id); + const bundleMetadata: any[] = METADATA.filter((metadatum) => { + return metadataIds.indexOf(metadatum.id) >= 0 + }); + res.json(toHALResponse(req, req.bundle, bundleMetadata)); + }); + + + 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 })); + const metadataIds: string[] = req.bitstream.relationships.metadata.data.map(obj => obj.id); + const bitstreamMetadata: any[] = METADATA.filter((metadatum) => { + return metadataIds.indexOf(metadatum.id) >= 0 + }); + res.json(toHALResponse(req, req.bitstream, bitstreamMetadata)); + }); + return router; } diff --git a/src/backend/bitstreams.ts b/src/backend/bitstreams.ts new file mode 100644 index 0000000000..b6a0076b7f --- /dev/null +++ b/src/backend/bitstreams.ts @@ -0,0 +1,42 @@ +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": "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": "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" + } +]; diff --git a/src/backend/bundles.ts b/src/backend/bundles.ts new file mode 100644 index 0000000000..1b06110e61 --- /dev/null +++ b/src/backend/bundles.ts @@ -0,0 +1,36 @@ +export const BUNDLES = [ + { + "_links": { + "self": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" }, + "items": [ + { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" } + ], + "bitstreams": [ + { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" }, + ], + "primaryBitstream": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" } + }, + "id": "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/21539b1d-9ef1-4eda-9c77-49565b5bfb78" } + ], + "bitstreams": [ + { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" }, + ], + "primaryBitstream": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" } + }, + "id": "a469c57a-abcf-45c3-83e4-b187ebd708fd", + "name": "THUMBNAIL", + "metadata": [ + { "key": "dc.title", "value": "THUMBNAIL", "language": "en" } + ] + } +]; diff --git a/src/backend/collections.ts b/src/backend/collections.ts index db42fa2f37..b29fa07e16 100644 --- a/src/backend/collections.ts +++ b/src/backend/collections.ts @@ -1,42 +1,70 @@ export const COLLECTIONS = [ { - "id": "9e32a2e2-6b91-4236-a361-995ccdc14c60", - "type": "collections", - "attributes": { - "name": "A Test Collection", - "handle": "123456789/5179", - "copyrightText": "

© 2005-2016 JOHN DOE SOME RIGHTS RESERVED

", - "introductoryText": "

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.

\r\n

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.

", - "shortDescription": "A collection for testing purposes", - "sidebarText": "

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

" + "_links": { + "self": { "href": "/collections/9e32a2e2-6b91-4236-a361-995ccdc14c60" }, + "items": [ + { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, + { "href": "/items/be8325f7-243b-49f4-8a4b-df2b793ff3b5" } + ] }, - "relationships": { - "items": { - "data": [ - { "type": "items", "id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, - { "type": "items", "id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5" } - ] + "id": "9e32a2e2-6b91-4236-a361-995ccdc14c60", + "name": "A Test Collection", + "handle": "123456789/5179", + "metadata": [ + { + "key": "dc.rights", + "value": "

© 2005-2016 JOHN DOE SOME RIGHTS RESERVED

", + "language": null + }, + { + "key": "dc.description", + "value": "

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.

\r\n

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.

", + "language": null + }, + { + "key": "dc.description.abstract", + "value": "A collection for testing purposes", + "language": null + }, + { + "key": "dc.description.tableofcontents", + "value": "

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

", + "language": null } - } + ] }, { - "id": "598ce822-c357-46f3-ab70-63724d02d6ad", - "type": "collections", - "attributes": { - "name": "Another Test Collection", - "handle": "123456789/6547", - "copyrightText": "

© 2005-2016 JOHN DOE SOME RIGHTS RESERVED

", - "introductoryText": "

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.

\r\n

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.

", - "shortDescription": "Another collection for testing purposes", - "sidebarText": "

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

" + "_links": { + "self": { "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" }, + "items": [ + { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, + { "href": "/items/be8325f7-243b-49f4-8a4b-df2b793ff3b5" } + ] }, - "relationships": { - "items": { - "data": [ - { "type": "items", "id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, - { "type": "items", "id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5" } - ] + "id": "598ce822-c357-46f3-ab70-63724d02d6ad", + "name": "Another Test Collection", + "handle": "123456789/6547", + "metadata": [ + { + "key": "dc.rights", + "value": "

© 2005-2016 JOHN DOE SOME RIGHTS RESERVED

", + "language": null + }, + { + "key": "dc.description", + "value": "

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.

\r\n

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.

", + "language": null + }, + { + "key": "dc.description.abstract", + "value": "Another collection for testing purposes", + "language": null + }, + { + "key": "dc.description.tableofcontents", + "value": "

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

", + "language": null } - } + ] } ]; diff --git a/src/backend/items.ts b/src/backend/items.ts index 9d85dce332..46698c3bc0 100644 --- a/src/backend/items.ts +++ b/src/backend/items.ts @@ -1,128 +1,164 @@ 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/21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, - "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/9e32a2e2-6b91-4236-a361-995ccdc14c60" + }, + { + "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" + } + ], + "bundles": [ + { + "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" + }, + { + "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" + } + ] }, + "id": "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/be8325f7-243b-49f4-8a4b-df2b793ff3b5" }, - "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/9e32a2e2-6b91-4236-a361-995ccdc14c60" + }, + { + "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" + } + ], + "bundles": [ + { + "href": "/bundles/b0176baa-d52e-4c20-a8e6-d586f2c70c76" + }, + { + "href": "/bundles/40b1cd3f-07ad-4ca6-9716-132671f93a15" + } + ] + }, + "id": "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" } - } + ] } ]; From 2e5441d6f7f8a6eaf0e91ad45da2b567e7387481 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 15 Feb 2017 15:57:38 +0100 Subject: [PATCH 02/21] Moved all objects to a single data store: the cache. --- config/environment.default.js | 6 +- src/app/core/core.module.ts | 4 +- src/app/core/core.reducers.ts | 5 +- .../core/data-services/cache/cache.actions.ts | 33 +++++++++++ .../core/data-services/cache/cache.reducer.ts | 58 +++++++++++++++++++ .../core/data-services/cache/cache.service.ts | 58 +++++++++++++++++++ .../collection/collection-data.effects.ts | 41 +++++++++---- .../collection/collection-data.service.ts | 19 +++--- .../collection-find-multiple.actions.ts | 6 +- .../collection-find-multiple.reducer.ts | 8 +-- .../collection-find-single.actions.ts | 6 +- .../collection-find-single.reducer.ts | 12 ++-- src/app/core/shared/dspace-object.model.ts | 11 +++- ...cache.service.ts => demo-cache.service.ts} | 6 +- src/app/shared/model/model.service.ts | 4 +- src/backend/api.ts | 2 +- src/browser.module.ts | 8 +-- src/node.module.ts | 8 +-- 18 files changed, 240 insertions(+), 55 deletions(-) create mode 100644 src/app/core/data-services/cache/cache.actions.ts create mode 100644 src/app/core/data-services/cache/cache.reducer.ts create mode 100644 src/app/core/data-services/cache/cache.service.ts rename src/app/shared/{cache.service.ts => demo-cache.service.ts} (95%) diff --git a/config/environment.default.js b/config/environment.default.js index 17d6f5afdc..a39db0dff8 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -10,5 +10,9 @@ module.exports = { "ui": { "nameSpace": "/", "baseURL": "http://localhost:3000" + }, + "cache": { + // how long should objects be cached for by default + "msToLive": 15 * 60 * 1000 //15 minutes } -} +}; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 7fece0601a..e7fb604c34 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -4,6 +4,7 @@ import { SharedModule } from "../shared/shared.module"; import { isNotEmpty } from "../shared/empty.util"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { CollectionDataService } from "./data-services/collection/collection-data.service"; +import { CacheService } from "./data-services/cache/cache.service"; const IMPORTS = [ CommonModule, @@ -18,7 +19,8 @@ const EXPORTS = [ const PROVIDERS = [ CollectionDataService, - DSpaceRESTv2Service + DSpaceRESTv2Service, + CacheService ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 47be0d13f3..9adea94095 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -3,13 +3,16 @@ import { CollectionDataState, collectionDataReducer } from "./data-services/collection/collection-data.reducer"; +import { CacheState, cacheReducer } from "./data-services/cache/cache.reducer"; export interface CoreState { - collectionData: CollectionDataState + collectionData: CollectionDataState, + cache: CacheState } export const reducers = { collectionData: collectionDataReducer, + cache: cacheReducer }; export function coreReducer(state: any, action: any) { diff --git a/src/app/core/data-services/cache/cache.actions.ts b/src/app/core/data-services/cache/cache.actions.ts new file mode 100644 index 0000000000..43146f5ce9 --- /dev/null +++ b/src/app/core/data-services/cache/cache.actions.ts @@ -0,0 +1,33 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../../shared/ngrx/type"; +import { CacheableObject } from "./cache.reducer"; + +export const CacheActionTypes = { + ADD: type('dspace/core/data/cache/ADD'), + REMOVE: type('dspace/core/data/cache/REMOVE') +}; + +export class AddToCacheAction implements Action { + type = CacheActionTypes.ADD; + payload: { + objectToCache: CacheableObject; + msToLive: number; + }; + + constructor(objectToCache: CacheableObject, msToLive: number) { + this.payload = { objectToCache, msToLive }; + } +} + +export class RemoveFromCacheAction implements Action { + type = CacheActionTypes.REMOVE; + payload: string; + + constructor(uuid: string) { + this.payload = uuid; + } +} + +export type CacheAction + = AddToCacheAction + | RemoveFromCacheAction diff --git a/src/app/core/data-services/cache/cache.reducer.ts b/src/app/core/data-services/cache/cache.reducer.ts new file mode 100644 index 0000000000..59b567e939 --- /dev/null +++ b/src/app/core/data-services/cache/cache.reducer.ts @@ -0,0 +1,58 @@ +import { CacheAction, CacheActionTypes, AddToCacheAction, RemoveFromCacheAction } from "./cache.actions"; +import { hasValue } from "../../../shared/empty.util"; + +export interface CacheableObject { + uuid: string; +} + +export interface CacheEntry { + data: CacheableObject; + timeAdded: number; + msToLive: number; +} + +export interface CacheState { + [uuid: string]: CacheEntry +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState: CacheState = Object.create(null); + +export const cacheReducer = (state = initialState, action: CacheAction): CacheState => { + switch (action.type) { + + case CacheActionTypes.ADD: { + return addToCache(state, action); + } + + case CacheActionTypes.REMOVE: { + return removeFromCache(state, action) + } + + default: { + return state; + } + } +}; + +function addToCache(state: CacheState, action: AddToCacheAction): CacheState { + return Object.assign({}, state, { + [action.payload.objectToCache.uuid]: { + data: action.payload.objectToCache, + timeAdded: new Date().getTime(), + msToLive: action.payload.msToLive + } + }); +} + +function removeFromCache(state: CacheState, action: RemoveFromCacheAction): CacheState { + if (hasValue(state[action.payload])) { + let newCache = Object.assign({}, state); + delete newCache[action.payload]; + + return newCache; + } + else { + return state; + } +} diff --git a/src/app/core/data-services/cache/cache.service.ts b/src/app/core/data-services/cache/cache.service.ts new file mode 100644 index 0000000000..d8c73ec1cb --- /dev/null +++ b/src/app/core/data-services/cache/cache.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { CacheState, CacheEntry, CacheableObject } from "./cache.reducer"; +import { AddToCacheAction, RemoveFromCacheAction } from "./cache.actions"; +import { Observable } from "rxjs"; +import { hasNoValue } from "../../../shared/empty.util"; + +@Injectable() +export class CacheService { + constructor( + private store: Store + ) {} + + add(objectToCache: CacheableObject, msToLive: number): void { + this.store.dispatch(new AddToCacheAction(objectToCache, msToLive)); + } + + remove(uuid: string): void { + this.store.dispatch(new RemoveFromCacheAction(uuid)); + } + + get(uuid: string): Observable { + return this.store.select('core', 'cache', uuid) + .filter(entry => this.isValid(entry)) + .map((entry: CacheEntry) => entry.data); + } + + getList(uuids: Array): Observable> { + return Observable.combineLatest( + uuids.map((id: string) => this.get(id)) + ); + } + + has(uuid: string): boolean { + let result: boolean; + + this.store.select('core', 'cache', uuid) + .take(1) + .subscribe(entry => result = this.isValid(entry)); + + return result; + } + + private isValid(entry: CacheEntry): 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 RemoveFromCacheAction(entry.data.uuid)); + } + return !isOutDated; + } + } + +} diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts index 7a5fdd4f6b..192b2e48b6 100644 --- a/src/app/core/data-services/collection/collection-data.effects.ts +++ b/src/app/core/data-services/collection/collection-data.effects.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { Actions, Effect } from "@ngrx/effects"; +import { Actions, Effect, toPayload } from "@ngrx/effects"; import { Collection } from "../../shared/collection.model"; import { Observable } from "rxjs"; import { @@ -15,33 +15,52 @@ import { import { DSpaceRESTV2Response } from "../../dspace-rest-v2/dspace-rest-v2-response.model"; import { DSpaceRESTv2Serializer } from "../../dspace-rest-v2/dspace-rest-v2.serializer"; import { DSpaceRESTv2Service } from "../../dspace-rest-v2/dspace-rest-v2.service"; +import { CacheService } from "../cache/cache.service"; +import { GlobalConfig } from "../../../../config"; @Injectable() export class CollectionDataEffects { constructor( private actions$: Actions, - private restApiService: DSpaceRESTv2Service + private restApi: DSpaceRESTv2Service, + private cache: CacheService ) {} + // TODO, results of a findall aren't retrieved from cache for now, + // because currently the cache is more of an object store. We need to move + // more towards memoization for things like this. @Effect() findAll$ = this.actions$ .ofType(CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST) .switchMap(() => { - return this.restApiService.get('/collections') + return this.restApi.get('/collections') .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserializeArray(data)) - .map((collections: Collection[]) => new CollectionFindMultipleSuccessAction(collections)) + .do((collections: Collection[]) => { + collections.forEach((collection) => { + this.cache.add(collection, GlobalConfig.cache.msToLive); + }); + }) + .map((collections: Array) => collections.map(collection => collection.id)) + .map((ids: Array) => new CollectionFindMultipleSuccessAction(ids)) .catch((errorMsg: string) => Observable.of(new CollectionFindMultipleErrorAction(errorMsg))); }); @Effect() findById$ = this.actions$ .ofType(CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST) .switchMap(action => { - return this.restApiService.get(`/collections/${action.payload}`) - .map((data: DSpaceRESTV2Response) => { - const t = new DSpaceRESTv2Serializer(Collection).deserialize(data); - return t; - }) - .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection)) - .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); + if (this.cache.has(action.payload)) { + return this.cache.get(action.payload) + .map(collection => new CollectionFindByIdSuccessAction(collection.id)); + } + else { + return this.restApi.get(`/collections/${action.payload}`) + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserialize(data)) + .do((collection: Collection) => { + this.cache.add(collection, GlobalConfig.cache.msToLive); + }) + .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.id)) + .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); + } }); + } diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts index b774b5cd0d..2468f8ad09 100644 --- a/src/app/core/data-services/collection/collection-data.service.ts +++ b/src/app/core/data-services/collection/collection-data.service.ts @@ -5,26 +5,29 @@ import { Store } from "@ngrx/store"; import { Collection } from "../../shared/collection.model"; import { CollectionFindMultipleRequestAction } from "./collection-find-multiple.actions"; import { CollectionFindByIdRequestAction } from "./collection-find-single.actions"; -import { isNotEmpty } from "../../../shared/empty.util"; -import 'rxjs/add/operator/filter'; +import { CacheService } from "../cache/cache.service"; +import 'rxjs/add/observable/forkJoin'; @Injectable() export class CollectionDataService { constructor( - private store: Store + private store: Store, + private cache: CacheService ) { } findAll(scope?: Collection): Observable { this.store.dispatch(new CollectionFindMultipleRequestAction(scope)); - return this.store.select('core', 'collectionData', 'findMultiple', 'collections'); + //get an observable of the IDs from the collectionData store + return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionsIDs') + .flatMap((collectionIds: Array) => { + // use those IDs to fetch the actual collection objects from the cache + return this.cache.getList(collectionIds); + }); } findById(id: string): Observable { this.store.dispatch(new CollectionFindByIdRequestAction(id)); - return this.store.select('core', 'collectionData', 'findSingle', 'collection') - //this filter is necessary because the same collection - //object in the state is used for every findById call - .filter(collection => isNotEmpty(collection) && collection.id === id); + return this.cache.get(id); } } diff --git a/src/app/core/data-services/collection/collection-find-multiple.actions.ts b/src/app/core/data-services/collection/collection-find-multiple.actions.ts index 95009602ec..a68f7e2478 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.actions.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.actions.ts @@ -33,10 +33,10 @@ export class CollectionFindMultipleRequestAction implements Action { export class CollectionFindMultipleSuccessAction implements Action { type = CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS; - payload: Collection[]; + payload: Array; - constructor(collections: Collection[]) { - this.payload = collections; + constructor(collectionIDs: Array) { + this.payload = collectionIDs; } } diff --git a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts index 9b4934317c..463155d0f5 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts @@ -8,7 +8,7 @@ import { export interface CollectionFindMultipleState { scope: Collection; - collections: Collection[]; + collectionsIDs: Array; isLoading: boolean; errorMessage: string; paginationOptions: PaginationOptions; @@ -17,7 +17,7 @@ export interface CollectionFindMultipleState { const initialState: CollectionFindMultipleState = { scope: undefined, - collections: [], + collectionsIDs: [], isLoading: false, errorMessage: undefined, paginationOptions: undefined, @@ -30,7 +30,7 @@ export const findMultipleReducer = (state = initialState, action: CollectionFind case CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST: { return Object.assign({}, state, { scope: action.payload.scope, - collections: [], + collectionsIDs: [], isLoading: true, errorMessage: undefined, paginationOptions: action.payload.paginationOptions, @@ -41,7 +41,7 @@ export const findMultipleReducer = (state = initialState, action: CollectionFind case CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS: { return Object.assign({}, state, { isLoading: false, - collections: action.payload, + collectionsIDs: action.payload, errorMessage: undefined }); } diff --git a/src/app/core/data-services/collection/collection-find-single.actions.ts b/src/app/core/data-services/collection/collection-find-single.actions.ts index 37ccc8c328..92335cd49e 100644 --- a/src/app/core/data-services/collection/collection-find-single.actions.ts +++ b/src/app/core/data-services/collection/collection-find-single.actions.ts @@ -19,10 +19,10 @@ export class CollectionFindByIdRequestAction implements Action { export class CollectionFindByIdSuccessAction implements Action { type = CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS; - payload: Collection; + payload: string; - constructor(collection: Collection) { - this.payload = collection; + constructor(collectionID: string) { + this.payload = collectionID; } } diff --git a/src/app/core/data-services/collection/collection-find-single.reducer.ts b/src/app/core/data-services/collection/collection-find-single.reducer.ts index 0fc0bf65cf..720efdbca1 100644 --- a/src/app/core/data-services/collection/collection-find-single.reducer.ts +++ b/src/app/core/data-services/collection/collection-find-single.reducer.ts @@ -5,17 +5,15 @@ import { } from "./collection-find-single.actions"; export interface CollectionFindSingleState { - collection: Collection; isLoading: boolean; errorMessage: string; - id: string; + collectionID: string; } const initialState: CollectionFindSingleState = { - collection: undefined, isLoading: false, errorMessage: undefined, - id: undefined, + collectionID: undefined }; export const findSingleReducer = (state = initialState, action: CollectionFindSingleAction): CollectionFindSingleState => { @@ -24,17 +22,15 @@ export const findSingleReducer = (state = initialState, action: CollectionFindSi case CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST: { return Object.assign({}, state, { isLoading: true, - id: action.payload, - collections: undefined, errorMessage: undefined, + collectionID: action.payload }); } case CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS: { return Object.assign({}, state, { isLoading: false, - collection: action.payload, - errorMessage: undefined + errorMessage: undefined, }); } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index ecb449e709..9f48b7ca45 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,11 +1,12 @@ import { autoserialize, autoserializeAs } from "cerialize"; import { Metadatum } from "./metadatum.model" import { isEmpty, isNotEmpty } from "../../shared/empty.util"; +import { CacheableObject } from "../data-services/cache/cache.reducer"; /** * An abstract model class for a DSpaceObject. */ -export abstract class DSpaceObject { +export abstract class DSpaceObject implements CacheableObject { /** * The identifier of this DSpaceObject @@ -64,4 +65,12 @@ export abstract class DSpaceObject { return undefined; } } + + get uuid(): string { + return this.id; + } + + set uuid(val: string) { + this.id = val; + } } diff --git a/src/app/shared/cache.service.ts b/src/app/shared/demo-cache.service.ts similarity index 95% rename from src/app/shared/cache.service.ts rename to src/app/shared/demo-cache.service.ts index 1268c90410..3bdc5ecb59 100644 --- a/src/app/shared/cache.service.ts +++ b/src/app/shared/demo-cache.service.ts @@ -1,8 +1,8 @@ import { Inject, Injectable, isDevMode } from '@angular/core'; @Injectable() -export class CacheService { - static KEY = 'CacheService'; +export class DemoCacheService { + static KEY = 'DemoCacheService'; constructor( @Inject('LRU') public _cache: Map) { @@ -71,7 +71,7 @@ export class CacheService { */ normalizeKey(key: string | number): string { if (isDevMode() && this._isInvalidValue(key)) { - throw new Error('Please provide a valid key to save in the CacheService'); + throw new Error('Please provide a valid key to save in the DemoCacheService'); } return key + ''; diff --git a/src/app/shared/model/model.service.ts b/src/app/shared/model/model.service.ts index 251a31542f..734f931894 100644 --- a/src/app/shared/model/model.service.ts +++ b/src/app/shared/model/model.service.ts @@ -4,7 +4,7 @@ import 'rxjs/add/observable/of'; import 'rxjs/add/operator/do'; import 'rxjs/add/operator/share'; -import { CacheService } from '../cache.service'; +import { DemoCacheService } from '../demo-cache.service'; import { ApiService } from '../api.service'; export function hashCodeString(str: string): string { @@ -24,7 +24,7 @@ export function hashCodeString(str: string): string { @Injectable() export class ModelService { // This is only one example of one Model depending on your domain - constructor(public _api: ApiService, public _cache: CacheService) { + constructor(public _api: ApiService, public _cache: DemoCacheService) { } diff --git a/src/backend/api.ts b/src/backend/api.ts index edf7cc3f20..613388dd57 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -92,7 +92,7 @@ export function createMockApi() { router.route('/collections/:collection_id') .get(function(req, res) { - console.log('GET', util.inspect(req.collection, { colors: true })); + console.log('GET', util.inspect(req.collection.id, { colors: true })); res.json(toHALResponse(req, req.collection)); // }) // .put(function(req, res) { diff --git a/src/browser.module.ts b/src/browser.module.ts index 27a39e9b40..5ef364fea6 100755 --- a/src/browser.module.ts +++ b/src/browser.module.ts @@ -10,7 +10,7 @@ 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 { DemoCacheService } from './app/shared/demo-cache.service'; import { CoreModule } from "./app/core/core.module"; // Will be merged into @angular/platform-browser in a later release @@ -70,7 +70,7 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; { provide: 'LRU', useFactory: getLRU, deps: [] }, - CacheService, + DemoCacheService, Meta, @@ -78,14 +78,14 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; ] }) export class MainModule { - constructor(public cache: CacheService) { + constructor(public cache: DemoCacheService) { // TODO(gdi2290): refactor into a lifecycle hook this.doRehydrate(); } doRehydrate() { let defaultValue = {}; - let serverCache = this._getCacheValue(CacheService.KEY, defaultValue); + let serverCache = this._getCacheValue(DemoCacheService.KEY, defaultValue); this.cache.rehydrate(serverCache); } diff --git a/src/node.module.ts b/src/node.module.ts index 1dc29d164c..6a554e0e8c 100755 --- a/src/node.module.ts +++ b/src/node.module.ts @@ -9,7 +9,7 @@ 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 { DemoCacheService } from './app/shared/demo-cache.service'; import { CoreModule } from "./app/core/core.module"; // Will be merged into @angular/platform-browser in a later release @@ -61,13 +61,13 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; { provide: 'LRU', useFactory: getLRU, deps: [] }, - CacheService, + DemoCacheService, Meta, ] }) export class MainModule { - constructor(public cache: CacheService) { + constructor(public cache: DemoCacheService) { } @@ -76,7 +76,7 @@ export class MainModule { * in Universal for now until it's fixed */ universalDoDehydrate = (universalCache) => { - universalCache[CacheService.KEY] = JSON.stringify(this.cache.dehydrate()); + universalCache[DemoCacheService.KEY] = JSON.stringify(this.cache.dehydrate()); } /** From 45a2f5b240edbd80e8f9f8a62f78f9e212a0916e Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Feb 2017 13:39:34 +0100 Subject: [PATCH 03/21] Added item services. --- src/app/core/core.effects.ts | 4 +- src/app/core/core.module.ts | 2 + src/app/core/core.reducers.ts | 3 + .../collection/collection-data.effects.ts | 2 +- .../collection/collection-data.service.ts | 4 +- .../collection-find-multiple.actions.ts | 7 +- .../collection-find-multiple.reducer.ts | 7 +- .../data-services/item/item-data.effects.ts | 66 +++++++++++++++++++ .../data-services/item/item-data.reducer.ts | 17 +++++ .../data-services/item/item-data.service.ts | 33 ++++++++++ .../item/item-find-multiple.actions.ts | 54 +++++++++++++++ .../item/item-find-multiple.reducer.ts | 59 +++++++++++++++++ .../item/item-find-single.actions.ts | 42 ++++++++++++ .../item/item-find-single.reducer.ts | 48 ++++++++++++++ 14 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 src/app/core/data-services/item/item-data.effects.ts create mode 100644 src/app/core/data-services/item/item-data.reducer.ts create mode 100644 src/app/core/data-services/item/item-data.service.ts create mode 100644 src/app/core/data-services/item/item-find-multiple.actions.ts create mode 100644 src/app/core/data-services/item/item-find-multiple.reducer.ts create mode 100644 src/app/core/data-services/item/item-find-single.actions.ts create mode 100644 src/app/core/data-services/item/item-find-single.reducer.ts diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index b2508bc25a..3c8dc9735f 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,6 +1,8 @@ import { EffectsModule } from "@ngrx/effects"; import { CollectionDataEffects } from "./data-services/collection/collection-data.effects"; +import { ItemDataEffects } from "./data-services/item/item-data.effects"; export const coreEffects = [ - EffectsModule.run(CollectionDataEffects) + EffectsModule.run(CollectionDataEffects), + EffectsModule.run(ItemDataEffects) ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e7fb604c34..af0543c530 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -5,6 +5,7 @@ import { isNotEmpty } from "../shared/empty.util"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { CollectionDataService } from "./data-services/collection/collection-data.service"; import { CacheService } from "./data-services/cache/cache.service"; +import { ItemDataService } from "./data-services/item/item-data.service"; const IMPORTS = [ CommonModule, @@ -19,6 +20,7 @@ const EXPORTS = [ const PROVIDERS = [ CollectionDataService, + ItemDataService, DSpaceRESTv2Service, CacheService ]; diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 9adea94095..b872b48af7 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -4,14 +4,17 @@ import { collectionDataReducer } from "./data-services/collection/collection-data.reducer"; import { CacheState, cacheReducer } from "./data-services/cache/cache.reducer"; +import { ItemDataState, itemDataReducer } from "./data-services/item/item-data.reducer"; export interface CoreState { collectionData: CollectionDataState, + itemData: ItemDataState, cache: CacheState } export const reducers = { collectionData: collectionDataReducer, + itemData: itemDataReducer, cache: cacheReducer }; diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts index 192b2e48b6..a9bb37c231 100644 --- a/src/app/core/data-services/collection/collection-data.effects.ts +++ b/src/app/core/data-services/collection/collection-data.effects.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { Actions, Effect, toPayload } from "@ngrx/effects"; +import { Actions, Effect } from "@ngrx/effects"; import { Collection } from "../../shared/collection.model"; import { Observable } from "rxjs"; import { diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts index 2468f8ad09..7f191eb603 100644 --- a/src/app/core/data-services/collection/collection-data.service.ts +++ b/src/app/core/data-services/collection/collection-data.service.ts @@ -15,8 +15,8 @@ export class CollectionDataService { private cache: CacheService ) { } - findAll(scope?: Collection): Observable { - this.store.dispatch(new CollectionFindMultipleRequestAction(scope)); + findAll(scopeID?: string): Observable { + this.store.dispatch(new CollectionFindMultipleRequestAction(scopeID)); //get an observable of the IDs from the collectionData store return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionsIDs') .flatMap((collectionIds: Array) => { diff --git a/src/app/core/data-services/collection/collection-find-multiple.actions.ts b/src/app/core/data-services/collection/collection-find-multiple.actions.ts index a68f7e2478..bdd657dc0f 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.actions.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.actions.ts @@ -1,6 +1,5 @@ import { Action } from "@ngrx/store"; import { type } from "../../../shared/ngrx/type"; -import { Collection } from "../../shared/collection.model"; import { PaginationOptions } from "../../shared/pagination-options.model"; import { SortOptions } from "../../shared/sort-options.model"; @@ -13,18 +12,18 @@ export const CollectionFindMultipleActionTypes = { export class CollectionFindMultipleRequestAction implements Action { type = CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST; payload: { - scope: Collection, + scopeID: string, paginationOptions: PaginationOptions, sortOptions: SortOptions }; constructor( - scope?: Collection, + scopeID?: string, paginationOptions: PaginationOptions = new PaginationOptions(), sortOptions: SortOptions = new SortOptions() ) { this.payload = { - scope, + scopeID, paginationOptions, sortOptions } diff --git a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts index 463155d0f5..2a3cf6776d 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts @@ -1,4 +1,3 @@ -import { Collection } from "../../shared/collection.model"; import { PaginationOptions } from "../../shared/pagination-options.model"; import { SortOptions } from "../../shared/sort-options.model"; import { @@ -7,7 +6,7 @@ import { } from "./collection-find-multiple.actions"; export interface CollectionFindMultipleState { - scope: Collection; + scopeID: string; collectionsIDs: Array; isLoading: boolean; errorMessage: string; @@ -16,7 +15,7 @@ export interface CollectionFindMultipleState { } const initialState: CollectionFindMultipleState = { - scope: undefined, + scopeID: undefined, collectionsIDs: [], isLoading: false, errorMessage: undefined, @@ -29,7 +28,7 @@ export const findMultipleReducer = (state = initialState, action: CollectionFind case CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST: { return Object.assign({}, state, { - scope: action.payload.scope, + scopeID: action.payload.scopeID, collectionsIDs: [], isLoading: true, errorMessage: undefined, diff --git a/src/app/core/data-services/item/item-data.effects.ts b/src/app/core/data-services/item/item-data.effects.ts new file mode 100644 index 0000000000..41554453d3 --- /dev/null +++ b/src/app/core/data-services/item/item-data.effects.ts @@ -0,0 +1,66 @@ +import { Injectable } from "@angular/core"; +import { Actions, Effect } from "@ngrx/effects"; +import { Item } from "../../shared/item.model"; +import { Observable } from "rxjs"; +import { + ItemFindMultipleActionTypes, + ItemFindMultipleSuccessAction, + ItemFindMultipleErrorAction +} from "./item-find-multiple.actions"; +import { + ItemFindSingleActionTypes, + ItemFindByIdSuccessAction, + ItemFindByIdErrorAction +} from "./item-find-single.actions"; +import { DSpaceRESTV2Response } from "../../dspace-rest-v2/dspace-rest-v2-response.model"; +import { DSpaceRESTv2Serializer } from "../../dspace-rest-v2/dspace-rest-v2.serializer"; +import { DSpaceRESTv2Service } from "../../dspace-rest-v2/dspace-rest-v2.service"; +import { CacheService } from "../cache/cache.service"; +import { GlobalConfig } from "../../../../config"; + + +@Injectable() +export class ItemDataEffects { + constructor( + private actions$: Actions, + private restApi: DSpaceRESTv2Service, + private cache: CacheService + ) {} + + // TODO, results of a findall aren't retrieved from cache for now, + // because currently the cache is more of an object store. We need to move + // more towards memoization for things like this. + @Effect() findAll$ = this.actions$ + .ofType(ItemFindMultipleActionTypes.FIND_MULTI_REQUEST) + .switchMap(() => { + return this.restApi.get('/items') + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserializeArray(data)) + .do((items: Item[]) => { + items.forEach((item) => { + this.cache.add(item, GlobalConfig.cache.msToLive); + }); + }) + .map((items: Array) => items.map(item => item.id)) + .map((ids: Array) => new ItemFindMultipleSuccessAction(ids)) + .catch((errorMsg: string) => Observable.of(new ItemFindMultipleErrorAction(errorMsg))); + }); + + @Effect() findById$ = this.actions$ + .ofType(ItemFindSingleActionTypes.FIND_BY_ID_REQUEST) + .switchMap(action => { + if (this.cache.has(action.payload)) { + return this.cache.get(action.payload) + .map(item => new ItemFindByIdSuccessAction(item.id)); + } + else { + return this.restApi.get(`/items/${action.payload}`) + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserialize(data)) + .do((item: Item) => { + this.cache.add(item, GlobalConfig.cache.msToLive); + }) + .map((item: Item) => new ItemFindByIdSuccessAction(item.id)) + .catch((errorMsg: string) => Observable.of(new ItemFindByIdErrorAction(errorMsg))); + } + }); + +} diff --git a/src/app/core/data-services/item/item-data.reducer.ts b/src/app/core/data-services/item/item-data.reducer.ts new file mode 100644 index 0000000000..20c0d805e5 --- /dev/null +++ b/src/app/core/data-services/item/item-data.reducer.ts @@ -0,0 +1,17 @@ +import { combineReducers } from "@ngrx/store"; +import { ItemFindMultipleState, findMultipleReducer } from "./item-find-multiple.reducer"; +import { ItemFindSingleState, findSingleReducer } from "./item-find-single.reducer"; + +export interface ItemDataState { + findMultiple: ItemFindMultipleState, + findSingle: ItemFindSingleState +} + +const reducers = { + findMultiple: findMultipleReducer, + findSingle: findSingleReducer +}; + +export function itemDataReducer(state: any, action: any) { + return combineReducers(reducers)(state, action); +} diff --git a/src/app/core/data-services/item/item-data.service.ts b/src/app/core/data-services/item/item-data.service.ts new file mode 100644 index 0000000000..a3701d9f7b --- /dev/null +++ b/src/app/core/data-services/item/item-data.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ItemDataState } from "./item-data.reducer"; +import { Store } from "@ngrx/store"; +import { Item } from "../../shared/item.model"; +import { ItemFindMultipleRequestAction } from "./item-find-multiple.actions"; +import { ItemFindByIdRequestAction } from "./item-find-single.actions"; +import { CacheService } from "../cache/cache.service"; +import 'rxjs/add/observable/forkJoin'; + +@Injectable() +export class ItemDataService { + constructor( + private store: Store, + private cache: CacheService + ) { } + + findAll(scopeID?: string): Observable { + this.store.dispatch(new ItemFindMultipleRequestAction(scopeID)); + //get an observable of the IDs from the itemData store + return this.store.select>('core', 'itemData', 'findMultiple', 'itemsIDs') + .flatMap((itemIds: Array) => { + // use those IDs to fetch the actual item objects from the cache + return this.cache.getList(itemIds); + }); + } + + findById(id: string): Observable { + this.store.dispatch(new ItemFindByIdRequestAction(id)); + return this.cache.get(id); + } + +} diff --git a/src/app/core/data-services/item/item-find-multiple.actions.ts b/src/app/core/data-services/item/item-find-multiple.actions.ts new file mode 100644 index 0000000000..6d90a1f604 --- /dev/null +++ b/src/app/core/data-services/item/item-find-multiple.actions.ts @@ -0,0 +1,54 @@ +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"; + +export const ItemFindMultipleActionTypes = { + FIND_MULTI_REQUEST: type('dspace/core/data/item/FIND_MULTI_REQUEST'), + FIND_MULTI_SUCCESS: type('dspace/core/data/item/FIND_MULTI_SUCCESS'), + FIND_MULTI_ERROR: type('dspace/core/data/item/FIND_MULTI_ERROR') +}; + +export class ItemFindMultipleRequestAction implements Action { + type = ItemFindMultipleActionTypes.FIND_MULTI_REQUEST; + payload: { + scopeID: string, + paginationOptions: PaginationOptions, + sortOptions: SortOptions + }; + + constructor( + scopeID?: string, + paginationOptions: PaginationOptions = new PaginationOptions(), + sortOptions: SortOptions = new SortOptions() + ) { + this.payload = { + scopeID, + paginationOptions, + sortOptions + } + } +} + +export class ItemFindMultipleSuccessAction implements Action { + type = ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS; + payload: Array; + + constructor(itemIDs: Array) { + this.payload = itemIDs; + } +} + +export class ItemFindMultipleErrorAction implements Action { + type = ItemFindMultipleActionTypes.FIND_MULTI_ERROR; + payload: string; + + constructor(errorMessage: string) { + this.payload = errorMessage; + } +} + +export type ItemFindMultipleAction + = ItemFindMultipleRequestAction + | ItemFindMultipleSuccessAction + | ItemFindMultipleErrorAction; diff --git a/src/app/core/data-services/item/item-find-multiple.reducer.ts b/src/app/core/data-services/item/item-find-multiple.reducer.ts new file mode 100644 index 0000000000..1621a63cd8 --- /dev/null +++ b/src/app/core/data-services/item/item-find-multiple.reducer.ts @@ -0,0 +1,59 @@ +import { PaginationOptions } from "../../shared/pagination-options.model"; +import { SortOptions } from "../../shared/sort-options.model"; +import { + ItemFindMultipleAction, + ItemFindMultipleActionTypes +} from "./item-find-multiple.actions"; + +export interface ItemFindMultipleState { + scopeID: string; + itemsIDs: Array; + isLoading: boolean; + errorMessage: string; + paginationOptions: PaginationOptions; + sortOptions: SortOptions; +} + +const initialState: ItemFindMultipleState = { + scopeID: undefined, + itemsIDs: [], + isLoading: false, + errorMessage: undefined, + paginationOptions: undefined, + sortOptions: undefined +}; + +export const findMultipleReducer = (state = initialState, action: ItemFindMultipleAction): ItemFindMultipleState => { + switch (action.type) { + + case ItemFindMultipleActionTypes.FIND_MULTI_REQUEST: { + return Object.assign({}, state, { + scopeID: action.payload.scopeID, + itemsIDs: [], + isLoading: true, + errorMessage: undefined, + paginationOptions: action.payload.paginationOptions, + sortOptions: action.payload.sortOptions + }); + } + + case ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS: { + return Object.assign({}, state, { + isLoading: false, + itemsIDs: action.payload, + errorMessage: undefined + }); + } + + case ItemFindMultipleActionTypes.FIND_MULTI_ERROR: { + return Object.assign({}, state, { + isLoading: false, + errorMessage: action.payload + }); + } + + default: { + return state; + } + } +}; diff --git a/src/app/core/data-services/item/item-find-single.actions.ts b/src/app/core/data-services/item/item-find-single.actions.ts new file mode 100644 index 0000000000..1e00fdb6f1 --- /dev/null +++ b/src/app/core/data-services/item/item-find-single.actions.ts @@ -0,0 +1,42 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../../shared/ngrx/type"; +import { Item } from "../../shared/item.model"; + +export const ItemFindSingleActionTypes = { + FIND_BY_ID_REQUEST: type('dspace/core/data/item/FIND_BY_ID_REQUEST'), + FIND_BY_ID_SUCCESS: type('dspace/core/data/item/FIND_BY_ID_SUCCESS'), + FIND_BY_ID_ERROR: type('dspace/core/data/item/FIND_BY_ID_ERROR') +}; + +export class ItemFindByIdRequestAction implements Action { + type = ItemFindSingleActionTypes.FIND_BY_ID_REQUEST; + payload: string; + + constructor(id: string) { + this.payload = id; + } +} + +export class ItemFindByIdSuccessAction implements Action { + type = ItemFindSingleActionTypes.FIND_BY_ID_SUCCESS; + payload: string; + + constructor(itemID: string) { + this.payload = itemID; + } +} + +export class ItemFindByIdErrorAction implements Action { + type = ItemFindSingleActionTypes.FIND_BY_ID_ERROR; + payload: string; + + constructor(errorMessage: string) { + this.payload = errorMessage; + } +} + +export type ItemFindSingleAction + = ItemFindByIdRequestAction + | ItemFindByIdSuccessAction + | ItemFindByIdErrorAction; + diff --git a/src/app/core/data-services/item/item-find-single.reducer.ts b/src/app/core/data-services/item/item-find-single.reducer.ts new file mode 100644 index 0000000000..5c11162cb0 --- /dev/null +++ b/src/app/core/data-services/item/item-find-single.reducer.ts @@ -0,0 +1,48 @@ +import { Item } from "../../shared/item.model"; +import { + ItemFindSingleAction, + ItemFindSingleActionTypes +} from "./item-find-single.actions"; + +export interface ItemFindSingleState { + isLoading: boolean; + errorMessage: string; + itemID: string; +} + +const initialState: ItemFindSingleState = { + isLoading: false, + errorMessage: undefined, + itemID: undefined +}; + +export const findSingleReducer = (state = initialState, action: ItemFindSingleAction): ItemFindSingleState => { + switch (action.type) { + + case ItemFindSingleActionTypes.FIND_BY_ID_REQUEST: { + return Object.assign({}, state, { + isLoading: true, + errorMessage: undefined, + itemID: action.payload + }); + } + + case ItemFindSingleActionTypes.FIND_BY_ID_SUCCESS: { + return Object.assign({}, state, { + isLoading: false, + errorMessage: undefined, + }); + } + + case ItemFindSingleActionTypes.FIND_BY_ID_ERROR: { + return Object.assign({}, state, { + isLoading: false, + errorMessage: action.payload + }); + } + + default: { + return state; + } + } +}; From 1fcd438a260f33ac94f25c6fafade5b7713ec638 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Feb 2017 13:49:22 +0100 Subject: [PATCH 04/21] Removed some json-api references from the mock api --- src/backend/api.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/backend/api.ts b/src/backend/api.ts index 613388dd57..1b86548195 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -155,11 +155,7 @@ 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(toHALResponse(req, req.item, itemMetadata)); + res.json(toHALResponse(req, req.item)); // }) // .put(function(req, res) { // console.log('PUT', util.inspect(req.body, { colors: true })); @@ -204,11 +200,7 @@ export function createMockApi() { router.route('/bundles/:bundle_id') .get(function(req, res) { console.log('GET', util.inspect(req.bundle, { colors: true })); - const metadataIds: string[] = req.bundle.relationships.metadata.data.map(obj => obj.id); - const bundleMetadata: any[] = METADATA.filter((metadatum) => { - return metadataIds.indexOf(metadatum.id) >= 0 - }); - res.json(toHALResponse(req, req.bundle, bundleMetadata)); + res.json(toHALResponse(req, req.bundle)); }); @@ -238,11 +230,7 @@ export function createMockApi() { router.route('/bitstreams/:bitstream_id') .get(function(req, res) { console.log('GET', util.inspect(req.bitstream, { colors: true })); - const metadataIds: string[] = req.bitstream.relationships.metadata.data.map(obj => obj.id); - const bitstreamMetadata: any[] = METADATA.filter((metadatum) => { - return metadataIds.indexOf(metadatum.id) >= 0 - }); - res.json(toHALResponse(req, req.bitstream, bitstreamMetadata)); + res.json(toHALResponse(req, req.bitstream)); }); return router; From 43a2b9699e73dcfa64a879963cdbe3697d833818 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Feb 2017 15:25:18 +0100 Subject: [PATCH 05/21] Made a clear distinction between IDs and UUIDs - as IDs will probably change in the future to be easier to work with (shorter urls) and applicable to multiple versions of a resource --- .../collection/collection-data.effects.ts | 8 ++++---- .../collection/collection-data.service.ts | 7 +++++-- .../collection/collection-find-multiple.actions.ts | 4 ++-- .../collection/collection-find-multiple.reducer.ts | 8 ++++---- .../collection/collection-find-single.actions.ts | 8 ++++---- .../collection/collection-find-single.reducer.ts | 9 ++++++--- src/app/core/data-services/item/item-data.effects.ts | 8 ++++---- src/app/core/data-services/item/item-data.service.ts | 11 +++++++---- .../data-services/item/item-find-multiple.actions.ts | 4 ++-- .../data-services/item/item-find-multiple.reducer.ts | 8 ++++---- .../data-services/item/item-find-single.actions.ts | 8 ++++---- .../data-services/item/item-find-single.reducer.ts | 9 ++++++--- 12 files changed, 52 insertions(+), 40 deletions(-) diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts index a9bb37c231..2b2d468a5c 100644 --- a/src/app/core/data-services/collection/collection-data.effects.ts +++ b/src/app/core/data-services/collection/collection-data.effects.ts @@ -40,8 +40,8 @@ export class CollectionDataEffects { this.cache.add(collection, GlobalConfig.cache.msToLive); }); }) - .map((collections: Array) => collections.map(collection => collection.id)) - .map((ids: Array) => new CollectionFindMultipleSuccessAction(ids)) + .map((collections: Array) => collections.map(collection => collection.uuid)) + .map((uuids: Array) => new CollectionFindMultipleSuccessAction(uuids)) .catch((errorMsg: string) => Observable.of(new CollectionFindMultipleErrorAction(errorMsg))); }); @@ -50,7 +50,7 @@ export class CollectionDataEffects { .switchMap(action => { if (this.cache.has(action.payload)) { return this.cache.get(action.payload) - .map(collection => new CollectionFindByIdSuccessAction(collection.id)); + .map(collection => new CollectionFindByIdSuccessAction(collection.uuid)); } else { return this.restApi.get(`/collections/${action.payload}`) @@ -58,7 +58,7 @@ export class CollectionDataEffects { .do((collection: Collection) => { this.cache.add(collection, GlobalConfig.cache.msToLive); }) - .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.id)) + .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.uuid)) .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); } }); diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts index 7f191eb603..3a7d69a724 100644 --- a/src/app/core/data-services/collection/collection-data.service.ts +++ b/src/app/core/data-services/collection/collection-data.service.ts @@ -18,7 +18,7 @@ export class CollectionDataService { findAll(scopeID?: string): Observable { this.store.dispatch(new CollectionFindMultipleRequestAction(scopeID)); //get an observable of the IDs from the collectionData store - return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionsIDs') + return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionUUIDs') .flatMap((collectionIds: Array) => { // use those IDs to fetch the actual collection objects from the cache return this.cache.getList(collectionIds); @@ -27,7 +27,10 @@ export class CollectionDataService { findById(id: string): Observable { this.store.dispatch(new CollectionFindByIdRequestAction(id)); - return this.cache.get(id); + return this.store.select('core', 'collectionData', 'findSingle', 'collectionUUID') + .flatMap((collectionUUID: string) => { + return this.cache.get(collectionUUID); + }); } } diff --git a/src/app/core/data-services/collection/collection-find-multiple.actions.ts b/src/app/core/data-services/collection/collection-find-multiple.actions.ts index bdd657dc0f..dd79b274c0 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.actions.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.actions.ts @@ -34,8 +34,8 @@ export class CollectionFindMultipleSuccessAction implements Action { type = CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS; payload: Array; - constructor(collectionIDs: Array) { - this.payload = collectionIDs; + constructor(collectionUUIDs: Array) { + this.payload = collectionUUIDs; } } diff --git a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts index 2a3cf6776d..e64ce5011b 100644 --- a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts +++ b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts @@ -7,7 +7,7 @@ import { export interface CollectionFindMultipleState { scopeID: string; - collectionsIDs: Array; + collectionUUIDs: Array; isLoading: boolean; errorMessage: string; paginationOptions: PaginationOptions; @@ -16,7 +16,7 @@ export interface CollectionFindMultipleState { const initialState: CollectionFindMultipleState = { scopeID: undefined, - collectionsIDs: [], + collectionUUIDs: [], isLoading: false, errorMessage: undefined, paginationOptions: undefined, @@ -29,7 +29,7 @@ export const findMultipleReducer = (state = initialState, action: CollectionFind case CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST: { return Object.assign({}, state, { scopeID: action.payload.scopeID, - collectionsIDs: [], + collectionUUIDs: [], isLoading: true, errorMessage: undefined, paginationOptions: action.payload.paginationOptions, @@ -40,7 +40,7 @@ export const findMultipleReducer = (state = initialState, action: CollectionFind case CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS: { return Object.assign({}, state, { isLoading: false, - collectionsIDs: action.payload, + collectionUUIDs: action.payload, errorMessage: undefined }); } diff --git a/src/app/core/data-services/collection/collection-find-single.actions.ts b/src/app/core/data-services/collection/collection-find-single.actions.ts index 92335cd49e..392dbe3482 100644 --- a/src/app/core/data-services/collection/collection-find-single.actions.ts +++ b/src/app/core/data-services/collection/collection-find-single.actions.ts @@ -12,8 +12,8 @@ export class CollectionFindByIdRequestAction implements Action { type = CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST; payload: string; - constructor(id: string) { - this.payload = id; + constructor(requestID: string) { + this.payload = requestID; } } @@ -21,8 +21,8 @@ export class CollectionFindByIdSuccessAction implements Action { type = CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS; payload: string; - constructor(collectionID: string) { - this.payload = collectionID; + constructor(collectionUUID: string) { + this.payload = collectionUUID; } } diff --git a/src/app/core/data-services/collection/collection-find-single.reducer.ts b/src/app/core/data-services/collection/collection-find-single.reducer.ts index 720efdbca1..49c330cdfb 100644 --- a/src/app/core/data-services/collection/collection-find-single.reducer.ts +++ b/src/app/core/data-services/collection/collection-find-single.reducer.ts @@ -7,13 +7,15 @@ import { export interface CollectionFindSingleState { isLoading: boolean; errorMessage: string; - collectionID: string; + requestedID: string; + collectionUUID: string; } const initialState: CollectionFindSingleState = { isLoading: false, errorMessage: undefined, - collectionID: undefined + requestedID: undefined, + collectionUUID: undefined }; export const findSingleReducer = (state = initialState, action: CollectionFindSingleAction): CollectionFindSingleState => { @@ -23,7 +25,7 @@ export const findSingleReducer = (state = initialState, action: CollectionFindSi return Object.assign({}, state, { isLoading: true, errorMessage: undefined, - collectionID: action.payload + requestedID: action.payload }); } @@ -31,6 +33,7 @@ export const findSingleReducer = (state = initialState, action: CollectionFindSi return Object.assign({}, state, { isLoading: false, errorMessage: undefined, + collectionUUID: action.payload }); } diff --git a/src/app/core/data-services/item/item-data.effects.ts b/src/app/core/data-services/item/item-data.effects.ts index 41554453d3..63336df3e9 100644 --- a/src/app/core/data-services/item/item-data.effects.ts +++ b/src/app/core/data-services/item/item-data.effects.ts @@ -40,8 +40,8 @@ export class ItemDataEffects { this.cache.add(item, GlobalConfig.cache.msToLive); }); }) - .map((items: Array) => items.map(item => item.id)) - .map((ids: Array) => new ItemFindMultipleSuccessAction(ids)) + .map((items: Array) => items.map(item => item.uuid)) + .map((uuids: Array) => new ItemFindMultipleSuccessAction(uuids)) .catch((errorMsg: string) => Observable.of(new ItemFindMultipleErrorAction(errorMsg))); }); @@ -50,7 +50,7 @@ export class ItemDataEffects { .switchMap(action => { if (this.cache.has(action.payload)) { return this.cache.get(action.payload) - .map(item => new ItemFindByIdSuccessAction(item.id)); + .map(item => new ItemFindByIdSuccessAction(item.uuid)); } else { return this.restApi.get(`/items/${action.payload}`) @@ -58,7 +58,7 @@ export class ItemDataEffects { .do((item: Item) => { this.cache.add(item, GlobalConfig.cache.msToLive); }) - .map((item: Item) => new ItemFindByIdSuccessAction(item.id)) + .map((item: Item) => new ItemFindByIdSuccessAction(item.uuid)) .catch((errorMsg: string) => Observable.of(new ItemFindByIdErrorAction(errorMsg))); } }); diff --git a/src/app/core/data-services/item/item-data.service.ts b/src/app/core/data-services/item/item-data.service.ts index a3701d9f7b..36fa5a91db 100644 --- a/src/app/core/data-services/item/item-data.service.ts +++ b/src/app/core/data-services/item/item-data.service.ts @@ -18,16 +18,19 @@ export class ItemDataService { findAll(scopeID?: string): Observable { this.store.dispatch(new ItemFindMultipleRequestAction(scopeID)); //get an observable of the IDs from the itemData store - return this.store.select>('core', 'itemData', 'findMultiple', 'itemsIDs') - .flatMap((itemIds: Array) => { + return this.store.select>('core', 'itemData', 'findMultiple', 'itemUUIDs') + .flatMap((itemUUIDs: Array) => { // use those IDs to fetch the actual item objects from the cache - return this.cache.getList(itemIds); + return this.cache.getList(itemUUIDs); }); } findById(id: string): Observable { this.store.dispatch(new ItemFindByIdRequestAction(id)); - return this.cache.get(id); + return this.store.select('core', 'itemData', 'findSingle', 'itemUUID') + .flatMap((itemUUID: string) => { + return this.cache.get(itemUUID); + }); } } diff --git a/src/app/core/data-services/item/item-find-multiple.actions.ts b/src/app/core/data-services/item/item-find-multiple.actions.ts index 6d90a1f604..72649dba85 100644 --- a/src/app/core/data-services/item/item-find-multiple.actions.ts +++ b/src/app/core/data-services/item/item-find-multiple.actions.ts @@ -34,8 +34,8 @@ export class ItemFindMultipleSuccessAction implements Action { type = ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS; payload: Array; - constructor(itemIDs: Array) { - this.payload = itemIDs; + constructor(itemUUIDs: Array) { + this.payload = itemUUIDs; } } diff --git a/src/app/core/data-services/item/item-find-multiple.reducer.ts b/src/app/core/data-services/item/item-find-multiple.reducer.ts index 1621a63cd8..bb3431b1fc 100644 --- a/src/app/core/data-services/item/item-find-multiple.reducer.ts +++ b/src/app/core/data-services/item/item-find-multiple.reducer.ts @@ -7,7 +7,7 @@ import { export interface ItemFindMultipleState { scopeID: string; - itemsIDs: Array; + itemUUIDs: Array; isLoading: boolean; errorMessage: string; paginationOptions: PaginationOptions; @@ -16,7 +16,7 @@ export interface ItemFindMultipleState { const initialState: ItemFindMultipleState = { scopeID: undefined, - itemsIDs: [], + itemUUIDs: [], isLoading: false, errorMessage: undefined, paginationOptions: undefined, @@ -29,7 +29,7 @@ export const findMultipleReducer = (state = initialState, action: ItemFindMultip case ItemFindMultipleActionTypes.FIND_MULTI_REQUEST: { return Object.assign({}, state, { scopeID: action.payload.scopeID, - itemsIDs: [], + itemUUIDs: [], isLoading: true, errorMessage: undefined, paginationOptions: action.payload.paginationOptions, @@ -40,7 +40,7 @@ export const findMultipleReducer = (state = initialState, action: ItemFindMultip case ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS: { return Object.assign({}, state, { isLoading: false, - itemsIDs: action.payload, + itemUUIDs: action.payload, errorMessage: undefined }); } diff --git a/src/app/core/data-services/item/item-find-single.actions.ts b/src/app/core/data-services/item/item-find-single.actions.ts index 1e00fdb6f1..f514d83474 100644 --- a/src/app/core/data-services/item/item-find-single.actions.ts +++ b/src/app/core/data-services/item/item-find-single.actions.ts @@ -12,8 +12,8 @@ export class ItemFindByIdRequestAction implements Action { type = ItemFindSingleActionTypes.FIND_BY_ID_REQUEST; payload: string; - constructor(id: string) { - this.payload = id; + constructor(requestID: string) { + this.payload = requestID; } } @@ -21,8 +21,8 @@ export class ItemFindByIdSuccessAction implements Action { type = ItemFindSingleActionTypes.FIND_BY_ID_SUCCESS; payload: string; - constructor(itemID: string) { - this.payload = itemID; + constructor(itemUUID: string) { + this.payload = itemUUID; } } diff --git a/src/app/core/data-services/item/item-find-single.reducer.ts b/src/app/core/data-services/item/item-find-single.reducer.ts index 5c11162cb0..83382728de 100644 --- a/src/app/core/data-services/item/item-find-single.reducer.ts +++ b/src/app/core/data-services/item/item-find-single.reducer.ts @@ -7,13 +7,15 @@ import { export interface ItemFindSingleState { isLoading: boolean; errorMessage: string; - itemID: string; + requestedID: string; + itemUUID: string; } const initialState: ItemFindSingleState = { isLoading: false, errorMessage: undefined, - itemID: undefined + requestedID: undefined, + itemUUID: undefined }; export const findSingleReducer = (state = initialState, action: ItemFindSingleAction): ItemFindSingleState => { @@ -23,7 +25,7 @@ export const findSingleReducer = (state = initialState, action: ItemFindSingleAc return Object.assign({}, state, { isLoading: true, errorMessage: undefined, - itemID: action.payload + requestedID: action.payload }); } @@ -31,6 +33,7 @@ export const findSingleReducer = (state = initialState, action: ItemFindSingleAc return Object.assign({}, state, { isLoading: false, errorMessage: undefined, + itemUUID: action.payload }); } From a9525576168e8eee541a58cf5f817b0bc02f4ec0 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Feb 2017 15:33:57 +0100 Subject: [PATCH 06/21] missed one --- .../core/data-services/collection/collection-data.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts index 3a7d69a724..262fcbc357 100644 --- a/src/app/core/data-services/collection/collection-data.service.ts +++ b/src/app/core/data-services/collection/collection-data.service.ts @@ -19,9 +19,9 @@ export class CollectionDataService { this.store.dispatch(new CollectionFindMultipleRequestAction(scopeID)); //get an observable of the IDs from the collectionData store return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionUUIDs') - .flatMap((collectionIds: Array) => { + .flatMap((collectionUUIDs: Array) => { // use those IDs to fetch the actual collection objects from the cache - return this.cache.getList(collectionIds); + return this.cache.getList(collectionUUIDs); }); } From de23b1aaebbcc8ef7642f8eb8d5f554a3a4a7da3 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Feb 2017 15:52:50 +0100 Subject: [PATCH 07/21] Added distinct ids and uuids to the mock data --- .../collection/collection-data.effects.ts | 20 +++++++------------ .../data-services/item/item-data.effects.ts | 20 +++++++------------ src/app/core/shared/dspace-object.model.ts | 16 +++++++-------- src/backend/bitstreams.ts | 6 ++++-- src/backend/bundles.ts | 10 ++++++---- src/backend/collections.ts | 18 +++++++++-------- src/backend/items.ts | 18 +++++++++-------- 7 files changed, 51 insertions(+), 57 deletions(-) diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts index 2b2d468a5c..9a8651944e 100644 --- a/src/app/core/data-services/collection/collection-data.effects.ts +++ b/src/app/core/data-services/collection/collection-data.effects.ts @@ -48,19 +48,13 @@ export class CollectionDataEffects { @Effect() findById$ = this.actions$ .ofType(CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST) .switchMap(action => { - if (this.cache.has(action.payload)) { - return this.cache.get(action.payload) - .map(collection => new CollectionFindByIdSuccessAction(collection.uuid)); - } - else { - return this.restApi.get(`/collections/${action.payload}`) - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserialize(data)) - .do((collection: Collection) => { - this.cache.add(collection, GlobalConfig.cache.msToLive); - }) - .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.uuid)) - .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); - } + return this.restApi.get(`/collections/${action.payload}`) + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserialize(data)) + .do((collection: Collection) => { + this.cache.add(collection, GlobalConfig.cache.msToLive); + }) + .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.uuid)) + .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); }); } diff --git a/src/app/core/data-services/item/item-data.effects.ts b/src/app/core/data-services/item/item-data.effects.ts index 63336df3e9..a8ccf9f223 100644 --- a/src/app/core/data-services/item/item-data.effects.ts +++ b/src/app/core/data-services/item/item-data.effects.ts @@ -48,19 +48,13 @@ export class ItemDataEffects { @Effect() findById$ = this.actions$ .ofType(ItemFindSingleActionTypes.FIND_BY_ID_REQUEST) .switchMap(action => { - if (this.cache.has(action.payload)) { - return this.cache.get(action.payload) - .map(item => new ItemFindByIdSuccessAction(item.uuid)); - } - else { - return this.restApi.get(`/items/${action.payload}`) - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserialize(data)) - .do((item: Item) => { - this.cache.add(item, GlobalConfig.cache.msToLive); - }) - .map((item: Item) => new ItemFindByIdSuccessAction(item.uuid)) - .catch((errorMsg: string) => Observable.of(new ItemFindByIdErrorAction(errorMsg))); - } + return this.restApi.get(`/items/${action.payload}`) + .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserialize(data)) + .do((item: Item) => { + this.cache.add(item, GlobalConfig.cache.msToLive); + }) + .map((item: Item) => new ItemFindByIdSuccessAction(item.uuid)) + .catch((errorMsg: string) => Observable.of(new ItemFindByIdErrorAction(errorMsg))); }); } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 9f48b7ca45..d9197db2b4 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -9,11 +9,17 @@ import { CacheableObject } from "../data-services/cache/cache.reducer"; export abstract class DSpaceObject implements CacheableObject { /** - * The identifier of this DSpaceObject + * 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, … */ @@ -65,12 +71,4 @@ export abstract class DSpaceObject implements CacheableObject { return undefined; } } - - get uuid(): string { - return this.id; - } - - set uuid(val: string) { - this.id = val; - } } diff --git a/src/backend/bitstreams.ts b/src/backend/bitstreams.ts index b6a0076b7f..480a0b4b55 100644 --- a/src/backend/bitstreams.ts +++ b/src/backend/bitstreams.ts @@ -5,7 +5,8 @@ export const BITSTREAMS = [ "bundle": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" }, "retrieve": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa/retrieve" } }, - "id": "43c57c2b-206f-4645-8c8f-5f10c84b09fa", + "id": "3678", + "uuid": "43c57c2b-206f-4645-8c8f-5f10c84b09fa", "name": "do_open_access_CRL.pdf", "size": 636626, "checksum": { @@ -25,7 +26,8 @@ export const BITSTREAMS = [ "bundle": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" }, "retrieve": { "href": "/rest/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632/retrieve" } }, - "id": "1a013ecc-fb25-4689-a44f-f1383ad26632", + "id": "8842", + "uuid": "1a013ecc-fb25-4689-a44f-f1383ad26632", "name": "do_open_access_CRL.pdf.jpg", "size": 41183, "checksum": { diff --git a/src/backend/bundles.ts b/src/backend/bundles.ts index 1b06110e61..01e8f07002 100644 --- a/src/backend/bundles.ts +++ b/src/backend/bundles.ts @@ -3,14 +3,15 @@ export const BUNDLES = [ "_links": { "self": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" }, "items": [ - { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" } + { "href": "/items/8871" } ], "bitstreams": [ { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" }, ], "primaryBitstream": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" } }, - "id": "35e0606d-5e18-4f9c-aa61-74fc751cc3f9", + "id": "2355", + "uuid": "35e0606d-5e18-4f9c-aa61-74fc751cc3f9", "name": "ORIGINAL", "metadata": [ { "key": "dc.title", "value": "ORIGINAL", "language": "en" } @@ -20,14 +21,15 @@ export const BUNDLES = [ "_links": { "self": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" }, "items": [ - { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" } + { "href": "/items/8871" } ], "bitstreams": [ { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" }, ], "primaryBitstream": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" } }, - "id": "a469c57a-abcf-45c3-83e4-b187ebd708fd", + "id": "5687", + "uuid": "a469c57a-abcf-45c3-83e4-b187ebd708fd", "name": "THUMBNAIL", "metadata": [ { "key": "dc.title", "value": "THUMBNAIL", "language": "en" } diff --git a/src/backend/collections.ts b/src/backend/collections.ts index b29fa07e16..1149d244f8 100644 --- a/src/backend/collections.ts +++ b/src/backend/collections.ts @@ -1,13 +1,14 @@ export const COLLECTIONS = [ { "_links": { - "self": { "href": "/collections/9e32a2e2-6b91-4236-a361-995ccdc14c60" }, + "self": { "href": "/collections/5179" }, "items": [ - { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, - { "href": "/items/be8325f7-243b-49f4-8a4b-df2b793ff3b5" } + { "href": "/items/8871" }, + { "href": "/items/9978" } ] }, - "id": "9e32a2e2-6b91-4236-a361-995ccdc14c60", + "id": "5179", + "uuid": "9e32a2e2-6b91-4236-a361-995ccdc14c60", "name": "A Test Collection", "handle": "123456789/5179", "metadata": [ @@ -35,13 +36,14 @@ export const COLLECTIONS = [ }, { "_links": { - "self": { "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" }, + "self": { "href": "/collections/6547" }, "items": [ - { "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" }, - { "href": "/items/be8325f7-243b-49f4-8a4b-df2b793ff3b5" } + { "href": "/items/8871" }, + { "href": "/items/9978" } ] }, - "id": "598ce822-c357-46f3-ab70-63724d02d6ad", + "id": "6547", + "uuid": "598ce822-c357-46f3-ab70-63724d02d6ad", "name": "Another Test Collection", "handle": "123456789/6547", "metadata": [ diff --git a/src/backend/items.ts b/src/backend/items.ts index 46698c3bc0..290e2b96aa 100644 --- a/src/backend/items.ts +++ b/src/backend/items.ts @@ -2,14 +2,14 @@ export const ITEMS = [ { "_links": { "self": { - "href": "/items/21539b1d-9ef1-4eda-9c77-49565b5bfb78" + "href": "/items/8871" }, "collections": [ { - "href": "/collections/9e32a2e2-6b91-4236-a361-995ccdc14c60" + "href": "/collections/5179" }, { - "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" + "href": "/collections/6547" } ], "bundles": [ @@ -21,7 +21,8 @@ export const ITEMS = [ } ] }, - "id": "21539b1d-9ef1-4eda-9c77-49565b5bfb78", + "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", @@ -93,14 +94,14 @@ export const ITEMS = [ { "_links": { "self": { - "href": "/items/be8325f7-243b-49f4-8a4b-df2b793ff3b5" + "href": "/items/9978" }, "collections": [ { - "href": "/collections/9e32a2e2-6b91-4236-a361-995ccdc14c60" + "href": "/collections/5179" }, { - "href": "/collections/598ce822-c357-46f3-ab70-63724d02d6ad" + "href": "/collections/6547" } ], "bundles": [ @@ -112,7 +113,8 @@ export const ITEMS = [ } ] }, - "id": "be8325f7-243b-49f4-8a4b-df2b793ff3b5", + "id": "9978", + "uuid": "be8325f7-243b-49f4-8a4b-df2b793ff3b5", "name": "Another Test Item", "handle": "123456789/9978", "lastModified": "2016-05-27 03:00:20.063", From 9364c32ae2436a2bb1ce72057c7fc11c40182774 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 21 Feb 2017 18:33:16 +0100 Subject: [PATCH 08/21] Refactored Data Services --- package.json | 1 + src/app/core/core.effects.ts | 4 +- src/app/core/core.module.ts | 4 +- src/app/core/core.reducers.ts | 12 +-- .../data-services/collection-data.effects.ts | 38 +++++++ .../data-services/collection-data.service.ts | 19 ++++ .../collection/collection-data.effects.ts | 60 ----------- .../collection/collection-data.reducer.ts | 17 --- .../collection/collection-data.service.ts | 36 ------- .../collection-find-multiple.actions.ts | 54 ---------- .../collection-find-multiple.reducer.ts | 59 ----------- .../collection-find-single.actions.ts | 42 -------- .../collection-find-single.reducer.ts | 51 --------- src/app/core/data-services/data.actions.ts | 96 +++++++++++++++++ src/app/core/data-services/data.effects.ts | 60 +++++++++++ src/app/core/data-services/data.reducer.ts | 100 ++++++++++++++++++ src/app/core/data-services/data.service.ts | 44 ++++++++ .../core/data-services/item-data.effects.ts | 38 +++++++ .../core/data-services/item-data.service.ts | 19 ++++ .../data-services/item/item-data.effects.ts | 60 ----------- .../data-services/item/item-data.reducer.ts | 17 --- .../data-services/item/item-data.service.ts | 36 ------- .../item/item-find-multiple.actions.ts | 54 ---------- .../item/item-find-multiple.reducer.ts | 59 ----------- .../item/item-find-single.actions.ts | 42 -------- .../item/item-find-single.reducer.ts | 51 --------- src/app/core/shared/param-hash.spec.ts | 58 ++++++++++ src/app/core/shared/param-hash.ts | 35 ++++++ 28 files changed, 515 insertions(+), 651 deletions(-) create mode 100644 src/app/core/data-services/collection-data.effects.ts create mode 100644 src/app/core/data-services/collection-data.service.ts delete mode 100644 src/app/core/data-services/collection/collection-data.effects.ts delete mode 100644 src/app/core/data-services/collection/collection-data.reducer.ts delete mode 100644 src/app/core/data-services/collection/collection-data.service.ts delete mode 100644 src/app/core/data-services/collection/collection-find-multiple.actions.ts delete mode 100644 src/app/core/data-services/collection/collection-find-multiple.reducer.ts delete mode 100644 src/app/core/data-services/collection/collection-find-single.actions.ts delete mode 100644 src/app/core/data-services/collection/collection-find-single.reducer.ts create mode 100644 src/app/core/data-services/data.actions.ts create mode 100644 src/app/core/data-services/data.effects.ts create mode 100644 src/app/core/data-services/data.reducer.ts create mode 100644 src/app/core/data-services/data.service.ts create mode 100644 src/app/core/data-services/item-data.effects.ts create mode 100644 src/app/core/data-services/item-data.service.ts delete mode 100644 src/app/core/data-services/item/item-data.effects.ts delete mode 100644 src/app/core/data-services/item/item-data.reducer.ts delete mode 100644 src/app/core/data-services/item/item-data.service.ts delete mode 100644 src/app/core/data-services/item/item-find-multiple.actions.ts delete mode 100644 src/app/core/data-services/item/item-find-multiple.reducer.ts delete mode 100644 src/app/core/data-services/item/item-find-single.actions.ts delete mode 100644 src/app/core/data-services/item/item-find-single.reducer.ts create mode 100644 src/app/core/shared/param-hash.spec.ts create mode 100644 src/app/core/shared/param-hash.ts diff --git a/package.json b/package.json index 8715037a40..4d318ebbd5 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,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" }, diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 3c8dc9735f..cc9835dd2d 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,6 +1,6 @@ import { EffectsModule } from "@ngrx/effects"; -import { CollectionDataEffects } from "./data-services/collection/collection-data.effects"; -import { ItemDataEffects } from "./data-services/item/item-data.effects"; +import { CollectionDataEffects } from "./data-services/collection-data.effects"; +import { ItemDataEffects } from "./data-services/item-data.effects"; export const coreEffects = [ EffectsModule.run(CollectionDataEffects), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index d70eb6e0b9..847f6f8287 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -4,9 +4,9 @@ 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 { CollectionDataService } from "./data-services/collection/collection-data.service"; import { CacheService } from "./data-services/cache/cache.service"; -import { ItemDataService } from "./data-services/item/item-data.service"; +import { CollectionDataService } from "./data-services/collection-data.service"; +import { ItemDataService } from "./data-services/item-data.service"; const IMPORTS = [ CommonModule, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index b872b48af7..d39039c499 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,20 +1,14 @@ import { combineReducers } from "@ngrx/store"; -import { - CollectionDataState, - collectionDataReducer -} from "./data-services/collection/collection-data.reducer"; import { CacheState, cacheReducer } from "./data-services/cache/cache.reducer"; -import { ItemDataState, itemDataReducer } from "./data-services/item/item-data.reducer"; +import { dataReducer, DataState } from "./data-services/data.reducer"; export interface CoreState { - collectionData: CollectionDataState, - itemData: ItemDataState, + data: DataState, cache: CacheState } export const reducers = { - collectionData: collectionDataReducer, - itemData: itemDataReducer, + data: dataReducer, cache: cacheReducer }; diff --git a/src/app/core/data-services/collection-data.effects.ts b/src/app/core/data-services/collection-data.effects.ts new file mode 100644 index 0000000000..9daacf999d --- /dev/null +++ b/src/app/core/data-services/collection-data.effects.ts @@ -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 { CacheService } from "./cache/cache.service"; +import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; +import { Actions, Effect } from "@ngrx/effects"; +import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { CollectionDataService } from "./collection-data.service"; + +@Injectable() +export class CollectionDataEffects extends DataEffects { + constructor( + actions$: Actions, + restApi: DSpaceRESTv2Service, + cache: CacheService, + dataService: CollectionDataService + ) { + super(actions$, restApi, cache, dataService); + } + + protected getFindAllEndpoint(action: DataFindAllRequestAction): string { + return '/collections'; + } + + protected getFindByIdEndpoint(action: DataFindByIDRequestAction): string { + return `/collections/${action.payload.resourceID}`; + } + + protected getSerializer(): Serializer { + return new DSpaceRESTv2Serializer(Collection); + } + + @Effect() findAll$ = this.findAll; + + @Effect() findById$ = this.findById; +} diff --git a/src/app/core/data-services/collection-data.service.ts b/src/app/core/data-services/collection-data.service.ts new file mode 100644 index 0000000000..9f4e2ed902 --- /dev/null +++ b/src/app/core/data-services/collection-data.service.ts @@ -0,0 +1,19 @@ +import { Injectable, OpaqueToken } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { DataService } from "./data.service"; +import { Collection } from "../shared/collection.model"; +import { CacheService } from "./cache/cache.service"; +import { DataState } from "./data.reducer"; + +@Injectable() +export class CollectionDataService extends DataService { + name = new OpaqueToken('CollectionDataService'); + + constructor( + store: Store, + cache: CacheService + ) { + super(store, cache); + } + +} diff --git a/src/app/core/data-services/collection/collection-data.effects.ts b/src/app/core/data-services/collection/collection-data.effects.ts deleted file mode 100644 index 9a8651944e..0000000000 --- a/src/app/core/data-services/collection/collection-data.effects.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Actions, Effect } from "@ngrx/effects"; -import { Collection } from "../../shared/collection.model"; -import { Observable } from "rxjs"; -import { - CollectionFindMultipleActionTypes, - CollectionFindMultipleSuccessAction, - CollectionFindMultipleErrorAction -} from "./collection-find-multiple.actions"; -import { - CollectionFindSingleActionTypes, - CollectionFindByIdSuccessAction, - CollectionFindByIdErrorAction -} from "./collection-find-single.actions"; -import { DSpaceRESTV2Response } from "../../dspace-rest-v2/dspace-rest-v2-response.model"; -import { DSpaceRESTv2Serializer } from "../../dspace-rest-v2/dspace-rest-v2.serializer"; -import { DSpaceRESTv2Service } from "../../dspace-rest-v2/dspace-rest-v2.service"; -import { CacheService } from "../cache/cache.service"; -import { GlobalConfig } from "../../../../config"; - - -@Injectable() -export class CollectionDataEffects { - constructor( - private actions$: Actions, - private restApi: DSpaceRESTv2Service, - private cache: CacheService - ) {} - - // TODO, results of a findall aren't retrieved from cache for now, - // because currently the cache is more of an object store. We need to move - // more towards memoization for things like this. - @Effect() findAll$ = this.actions$ - .ofType(CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST) - .switchMap(() => { - return this.restApi.get('/collections') - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserializeArray(data)) - .do((collections: Collection[]) => { - collections.forEach((collection) => { - this.cache.add(collection, GlobalConfig.cache.msToLive); - }); - }) - .map((collections: Array) => collections.map(collection => collection.uuid)) - .map((uuids: Array) => new CollectionFindMultipleSuccessAction(uuids)) - .catch((errorMsg: string) => Observable.of(new CollectionFindMultipleErrorAction(errorMsg))); - }); - - @Effect() findById$ = this.actions$ - .ofType(CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST) - .switchMap(action => { - return this.restApi.get(`/collections/${action.payload}`) - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Collection).deserialize(data)) - .do((collection: Collection) => { - this.cache.add(collection, GlobalConfig.cache.msToLive); - }) - .map((collection: Collection) => new CollectionFindByIdSuccessAction(collection.uuid)) - .catch((errorMsg: string) => Observable.of(new CollectionFindByIdErrorAction(errorMsg))); - }); - -} diff --git a/src/app/core/data-services/collection/collection-data.reducer.ts b/src/app/core/data-services/collection/collection-data.reducer.ts deleted file mode 100644 index bb9adc95cc..0000000000 --- a/src/app/core/data-services/collection/collection-data.reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { combineReducers } from "@ngrx/store"; -import { CollectionFindMultipleState, findMultipleReducer } from "./collection-find-multiple.reducer"; -import { CollectionFindSingleState, findSingleReducer } from "./collection-find-single.reducer"; - -export interface CollectionDataState { - findMultiple: CollectionFindMultipleState, - findSingle: CollectionFindSingleState -} - -const reducers = { - findMultiple: findMultipleReducer, - findSingle: findSingleReducer -}; - -export function collectionDataReducer(state: any, action: any) { - return combineReducers(reducers)(state, action); -} diff --git a/src/app/core/data-services/collection/collection-data.service.ts b/src/app/core/data-services/collection/collection-data.service.ts deleted file mode 100644 index 262fcbc357..0000000000 --- a/src/app/core/data-services/collection/collection-data.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Observable } from "rxjs"; -import { CollectionDataState } from "./collection-data.reducer"; -import { Store } from "@ngrx/store"; -import { Collection } from "../../shared/collection.model"; -import { CollectionFindMultipleRequestAction } from "./collection-find-multiple.actions"; -import { CollectionFindByIdRequestAction } from "./collection-find-single.actions"; -import { CacheService } from "../cache/cache.service"; -import 'rxjs/add/observable/forkJoin'; - -@Injectable() -export class CollectionDataService { - constructor( - private store: Store, - private cache: CacheService - ) { } - - findAll(scopeID?: string): Observable { - this.store.dispatch(new CollectionFindMultipleRequestAction(scopeID)); - //get an observable of the IDs from the collectionData store - return this.store.select>('core', 'collectionData', 'findMultiple', 'collectionUUIDs') - .flatMap((collectionUUIDs: Array) => { - // use those IDs to fetch the actual collection objects from the cache - return this.cache.getList(collectionUUIDs); - }); - } - - findById(id: string): Observable { - this.store.dispatch(new CollectionFindByIdRequestAction(id)); - return this.store.select('core', 'collectionData', 'findSingle', 'collectionUUID') - .flatMap((collectionUUID: string) => { - return this.cache.get(collectionUUID); - }); - } - -} diff --git a/src/app/core/data-services/collection/collection-find-multiple.actions.ts b/src/app/core/data-services/collection/collection-find-multiple.actions.ts deleted file mode 100644 index dd79b274c0..0000000000 --- a/src/app/core/data-services/collection/collection-find-multiple.actions.ts +++ /dev/null @@ -1,54 +0,0 @@ -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"; - -export const CollectionFindMultipleActionTypes = { - FIND_MULTI_REQUEST: type('dspace/core/data/collection/FIND_MULTI_REQUEST'), - FIND_MULTI_SUCCESS: type('dspace/core/data/collection/FIND_MULTI_SUCCESS'), - FIND_MULTI_ERROR: type('dspace/core/data/collection/FIND_MULTI_ERROR') -}; - -export class CollectionFindMultipleRequestAction implements Action { - type = CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST; - payload: { - scopeID: string, - paginationOptions: PaginationOptions, - sortOptions: SortOptions - }; - - constructor( - scopeID?: string, - paginationOptions: PaginationOptions = new PaginationOptions(), - sortOptions: SortOptions = new SortOptions() - ) { - this.payload = { - scopeID, - paginationOptions, - sortOptions - } - } -} - -export class CollectionFindMultipleSuccessAction implements Action { - type = CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS; - payload: Array; - - constructor(collectionUUIDs: Array) { - this.payload = collectionUUIDs; - } -} - -export class CollectionFindMultipleErrorAction implements Action { - type = CollectionFindMultipleActionTypes.FIND_MULTI_ERROR; - payload: string; - - constructor(errorMessage: string) { - this.payload = errorMessage; - } -} - -export type CollectionFindMultipleAction - = CollectionFindMultipleRequestAction - | CollectionFindMultipleSuccessAction - | CollectionFindMultipleErrorAction; diff --git a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts b/src/app/core/data-services/collection/collection-find-multiple.reducer.ts deleted file mode 100644 index e64ce5011b..0000000000 --- a/src/app/core/data-services/collection/collection-find-multiple.reducer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { PaginationOptions } from "../../shared/pagination-options.model"; -import { SortOptions } from "../../shared/sort-options.model"; -import { - CollectionFindMultipleAction, - CollectionFindMultipleActionTypes -} from "./collection-find-multiple.actions"; - -export interface CollectionFindMultipleState { - scopeID: string; - collectionUUIDs: Array; - isLoading: boolean; - errorMessage: string; - paginationOptions: PaginationOptions; - sortOptions: SortOptions; -} - -const initialState: CollectionFindMultipleState = { - scopeID: undefined, - collectionUUIDs: [], - isLoading: false, - errorMessage: undefined, - paginationOptions: undefined, - sortOptions: undefined -}; - -export const findMultipleReducer = (state = initialState, action: CollectionFindMultipleAction): CollectionFindMultipleState => { - switch (action.type) { - - case CollectionFindMultipleActionTypes.FIND_MULTI_REQUEST: { - return Object.assign({}, state, { - scopeID: action.payload.scopeID, - collectionUUIDs: [], - isLoading: true, - errorMessage: undefined, - paginationOptions: action.payload.paginationOptions, - sortOptions: action.payload.sortOptions - }); - } - - case CollectionFindMultipleActionTypes.FIND_MULTI_SUCCESS: { - return Object.assign({}, state, { - isLoading: false, - collectionUUIDs: action.payload, - errorMessage: undefined - }); - } - - case CollectionFindMultipleActionTypes.FIND_MULTI_ERROR: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: action.payload - }); - } - - default: { - return state; - } - } -}; diff --git a/src/app/core/data-services/collection/collection-find-single.actions.ts b/src/app/core/data-services/collection/collection-find-single.actions.ts deleted file mode 100644 index 392dbe3482..0000000000 --- a/src/app/core/data-services/collection/collection-find-single.actions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Action } from "@ngrx/store"; -import { type } from "../../../shared/ngrx/type"; -import { Collection } from "../../shared/collection.model"; - -export const CollectionFindSingleActionTypes = { - FIND_BY_ID_REQUEST: type('dspace/core/data/collection/FIND_BY_ID_REQUEST'), - FIND_BY_ID_SUCCESS: type('dspace/core/data/collection/FIND_BY_ID_SUCCESS'), - FIND_BY_ID_ERROR: type('dspace/core/data/collection/FIND_BY_ID_ERROR') -}; - -export class CollectionFindByIdRequestAction implements Action { - type = CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST; - payload: string; - - constructor(requestID: string) { - this.payload = requestID; - } -} - -export class CollectionFindByIdSuccessAction implements Action { - type = CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS; - payload: string; - - constructor(collectionUUID: string) { - this.payload = collectionUUID; - } -} - -export class CollectionFindByIdErrorAction implements Action { - type = CollectionFindSingleActionTypes.FIND_BY_ID_ERROR; - payload: string; - - constructor(errorMessage: string) { - this.payload = errorMessage; - } -} - -export type CollectionFindSingleAction - = CollectionFindByIdRequestAction - | CollectionFindByIdSuccessAction - | CollectionFindByIdErrorAction; - diff --git a/src/app/core/data-services/collection/collection-find-single.reducer.ts b/src/app/core/data-services/collection/collection-find-single.reducer.ts deleted file mode 100644 index 49c330cdfb..0000000000 --- a/src/app/core/data-services/collection/collection-find-single.reducer.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Collection } from "../../shared/collection.model"; -import { - CollectionFindSingleAction, - CollectionFindSingleActionTypes -} from "./collection-find-single.actions"; - -export interface CollectionFindSingleState { - isLoading: boolean; - errorMessage: string; - requestedID: string; - collectionUUID: string; -} - -const initialState: CollectionFindSingleState = { - isLoading: false, - errorMessage: undefined, - requestedID: undefined, - collectionUUID: undefined -}; - -export const findSingleReducer = (state = initialState, action: CollectionFindSingleAction): CollectionFindSingleState => { - switch (action.type) { - - case CollectionFindSingleActionTypes.FIND_BY_ID_REQUEST: { - return Object.assign({}, state, { - isLoading: true, - errorMessage: undefined, - requestedID: action.payload - }); - } - - case CollectionFindSingleActionTypes.FIND_BY_ID_SUCCESS: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: undefined, - collectionUUID: action.payload - }); - } - - case CollectionFindSingleActionTypes.FIND_BY_ID_ERROR: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: action.payload - }); - } - - default: { - return state; - } - } -}; diff --git a/src/app/core/data-services/data.actions.ts b/src/app/core/data-services/data.actions.ts new file mode 100644 index 0000000000..caf37331b0 --- /dev/null +++ b/src/app/core/data-services/data.actions.ts @@ -0,0 +1,96 @@ +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"; + +export const DataActionTypes = { + FIND_BY_ID_REQUEST: type('dspace/core/data/FIND_BY_ID_REQUEST'), + FIND_ALL_REQUEST: type('dspace/core/data/FIND_ALL_REQUEST'), + SUCCESS: type('dspace/core/data/SUCCESS'), + ERROR: type('dspace/core/data/ERROR') +}; + +export class DataFindAllRequestAction implements Action { + type = DataActionTypes.FIND_ALL_REQUEST; + payload: { + key: string, + service: OpaqueToken, + scopeID: string, + paginationOptions: PaginationOptions, + sortOptions: SortOptions + }; + + constructor( + key: string, + service: OpaqueToken, + scopeID?: string, + paginationOptions: PaginationOptions = new PaginationOptions(), + sortOptions: SortOptions = new SortOptions() + ) { + this.payload = { + key, + service, + scopeID, + paginationOptions, + sortOptions + } + } +} + +export class DataFindByIDRequestAction implements Action { + type = DataActionTypes.FIND_BY_ID_REQUEST; + payload: { + key: string, + service: OpaqueToken, + resourceID: string + }; + + constructor( + key: string, + service: OpaqueToken, + resourceID: string + ) { + this.payload = { + key, + service, + resourceID + } + } +} + +export class DataSuccessAction implements Action { + type = DataActionTypes.SUCCESS; + payload: { + key: string, + resourceUUIDs: Array + }; + + constructor(key: string, resourceUUIDs: Array) { + this.payload = { + key, + resourceUUIDs + }; + } +} + +export class DataErrorAction implements Action { + type = DataActionTypes.ERROR; + payload: { + key: string, + errorMessage: string + }; + + constructor(key: string, errorMessage: string) { + this.payload = { + key, + errorMessage + }; + } +} + +export type DataAction + = DataFindAllRequestAction + | DataFindByIDRequestAction + | DataSuccessAction + | DataErrorAction; diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts new file mode 100644 index 0000000000..362471bdfb --- /dev/null +++ b/src/app/core/data-services/data.effects.ts @@ -0,0 +1,60 @@ +import { Actions, Effect } 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 { CacheService } from "./cache/cache.service"; +import { GlobalConfig } from "../../../config"; +import { CacheableObject } from "./cache/cache.reducer"; +import { Serializer } from "../serializer"; +import { + DataActionTypes, DataFindAllRequestAction, DataSuccessAction, + DataErrorAction, DataFindByIDRequestAction, DataAction +} from "./data.actions"; +import { DataService } from "./data.service"; + +export abstract class DataEffects { + protected abstract getFindAllEndpoint(action: DataFindAllRequestAction): string; + protected abstract getFindByIdEndpoint(action: DataFindByIDRequestAction): string; + protected abstract getSerializer(): Serializer; + + constructor( + private actions$: Actions, + private restApi: DSpaceRESTv2Service, + private cache: CacheService, + private dataService: DataService + ) {} + + // TODO, results of a findall aren't retrieved from cache for now, + // because currently the cache is more of an object store. We need to move + // more towards memoization for things like this. + protected findAll = this.actions$ + .ofType(DataActionTypes.FIND_ALL_REQUEST) + .filter((action: DataFindAllRequestAction) => action.payload.service === this.dataService.name) + .switchMap((action: DataFindAllRequestAction) => { + //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) => { + this.cache.add(t, GlobalConfig.cache.msToLive); + }); + }) + .map((ts: Array) => ts.map(t => t.uuid)) + .map((ids: Array) => new DataSuccessAction(action.payload.key, ids)) + .catch((errorMsg: string) => Observable.of(new DataErrorAction(action.payload.key, errorMsg))); + }); + + protected findById = this.actions$ + .ofType(DataActionTypes.FIND_BY_ID_REQUEST) + .filter((action: DataFindAllRequestAction) => action.payload.service === this.dataService.name) + .switchMap((action: DataFindByIDRequestAction) => { + return this.restApi.get(this.getFindByIdEndpoint(action)) + .map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data)) + .do((t: T) => { + this.cache.add(t, GlobalConfig.cache.msToLive); + }) + .map((t: T) => new DataSuccessAction(action.payload.key, [t.uuid])) + .catch((errorMsg: string) => Observable.of(new DataErrorAction(action.payload.key, errorMsg))); + }); + +} diff --git a/src/app/core/data-services/data.reducer.ts b/src/app/core/data-services/data.reducer.ts new file mode 100644 index 0000000000..3641e790c7 --- /dev/null +++ b/src/app/core/data-services/data.reducer.ts @@ -0,0 +1,100 @@ +import { PaginationOptions } from "../shared/pagination-options.model"; +import { SortOptions } from "../shared/sort-options.model"; +import { + DataAction, DataActionTypes, DataFindAllRequestAction, + DataSuccessAction, DataErrorAction, DataFindByIDRequestAction +} from "./data.actions"; +import { OpaqueToken } from "@angular/core"; + +export interface DataRequestState { + service: OpaqueToken + scopeID: string; + resourceID: string; + resourceUUIDs: Array; + resourceType: String; + isLoading: boolean; + errorMessage: string; + paginationOptions: PaginationOptions; + sortOptions: SortOptions; + timeAdded: number; + msToLive: number; +} + +export interface DataState { + [key: string]: DataRequestState +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +export const dataReducer = (state = initialState, action: DataAction): DataState => { + switch (action.type) { + + case DataActionTypes.FIND_ALL_REQUEST: { + return findAllRequest(state, action); + } + + case DataActionTypes.FIND_BY_ID_REQUEST: { + return findByIDRequest(state, action); + } + + case DataActionTypes.SUCCESS: { + return success(state, action); + } + + case DataActionTypes.ERROR: { + return error(state, action); + } + + default: { + return state; + } + } +}; + +function findAllRequest(state: DataState, action: DataFindAllRequestAction): DataState { + return Object.assign({}, state, { + [action.payload.key]: { + service: action.payload.service, + scopeID: action.payload.scopeID, + resourceUUIDs: [], + isLoading: true, + errorMessage: undefined, + paginationOptions: action.payload.paginationOptions, + sortOptions: action.payload.sortOptions + } + }); +} + +function findByIDRequest(state: DataState, action: DataFindByIDRequestAction): DataState { + return Object.assign({}, state, { + [action.payload.key]: { + service: action.payload.service, + resourceID: action.payload.resourceID, + resourceUUIDs: [], + isLoading: true, + errorMessage: undefined, + } + }); +} + +function success(state: DataState, action: DataSuccessAction): DataState { + return Object.assign({}, state, { + [action.payload.key]: Object.assign({}, state[action.payload.key], { + isLoading: false, + resourceUUIDs: action.payload.resourceUUIDs, + errorMessage: undefined + }) + }); +} + +function error(state: DataState, action: DataErrorAction): DataState { + return Object.assign({}, state, { + [action.payload.key]: Object.assign({}, state[action.payload.key], { + isLoading: false, + errorMessage: action.payload.errorMessage + }) + }); +} + + diff --git a/src/app/core/data-services/data.service.ts b/src/app/core/data-services/data.service.ts new file mode 100644 index 0000000000..26a9e4e377 --- /dev/null +++ b/src/app/core/data-services/data.service.ts @@ -0,0 +1,44 @@ +import { OpaqueToken } from "@angular/core"; +import { Observable } from "rxjs"; +import { Store } from "@ngrx/store"; +import { CacheService } from "./cache/cache.service"; +import { CacheableObject } from "./cache/cache.reducer"; +import { DataState } from "./data.reducer"; +import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { ParamHash } from "../shared/param-hash"; +import { isNotEmpty } from "../../shared/empty.util"; + +export abstract class DataService { + abstract name: OpaqueToken; + + constructor( + private store: Store, + private cache: CacheService + ) { } + + findAll(scopeID?: string): Observable> { + const key = new ParamHash(this.name, 'findAll', scopeID).toString(); + this.store.dispatch(new DataFindAllRequestAction(key, this.name, scopeID)); + //get an observable of the IDs from the store + return this.store.select>('core', 'data', key, 'resourceUUIDs') + .flatMap((resourceUUIDs: Array) => { + // use those IDs to fetch the actual objects from the cache + return this.cache.getList(resourceUUIDs); + }); + } + + findById(id: string): Observable { + const key = new ParamHash(this.name, 'findById', id).toString(); + this.store.dispatch(new DataFindByIDRequestAction(key, this.name, id)); + return this.store.select>('core', 'data', key, 'resourceUUIDs') + .flatMap((resourceUUIDs: Array) => { + if(isNotEmpty(resourceUUIDs)) { + return this.cache.get(resourceUUIDs[0]); + } + else { + return Observable.of(undefined); + } + }); + } + +} diff --git a/src/app/core/data-services/item-data.effects.ts b/src/app/core/data-services/item-data.effects.ts new file mode 100644 index 0000000000..3976691e48 --- /dev/null +++ b/src/app/core/data-services/item-data.effects.ts @@ -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 { CacheService } from "./cache/cache.service"; +import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; +import { Actions, Effect } from "@ngrx/effects"; +import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { ItemDataService } from "./item-data.service"; + +@Injectable() +export class ItemDataEffects extends DataEffects { + constructor( + actions$: Actions, + restApi: DSpaceRESTv2Service, + cache: CacheService, + dataService: ItemDataService + ) { + super(actions$, restApi, cache, dataService); + } + + protected getFindAllEndpoint(action: DataFindAllRequestAction): string { + return '/items'; + } + + protected getFindByIdEndpoint(action: DataFindByIDRequestAction): string { + return `/items/${action.payload.resourceID}`; + } + + protected getSerializer(): Serializer { + return new DSpaceRESTv2Serializer(Item); + } + + @Effect() findAll$ = this.findAll; + + @Effect() findById$ = this.findById; +} diff --git a/src/app/core/data-services/item-data.service.ts b/src/app/core/data-services/item-data.service.ts new file mode 100644 index 0000000000..2d7128289a --- /dev/null +++ b/src/app/core/data-services/item-data.service.ts @@ -0,0 +1,19 @@ +import { Injectable, OpaqueToken } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { DataService } from "./data.service"; +import { Item } from "../shared/item.model"; +import { CacheService } from "./cache/cache.service"; +import { DataState } from "./data.reducer"; + +@Injectable() +export class ItemDataService extends DataService { + name = new OpaqueToken('ItemDataService'); + + constructor( + store: Store, + cache: CacheService + ) { + super(store, cache); + } + +} diff --git a/src/app/core/data-services/item/item-data.effects.ts b/src/app/core/data-services/item/item-data.effects.ts deleted file mode 100644 index a8ccf9f223..0000000000 --- a/src/app/core/data-services/item/item-data.effects.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Actions, Effect } from "@ngrx/effects"; -import { Item } from "../../shared/item.model"; -import { Observable } from "rxjs"; -import { - ItemFindMultipleActionTypes, - ItemFindMultipleSuccessAction, - ItemFindMultipleErrorAction -} from "./item-find-multiple.actions"; -import { - ItemFindSingleActionTypes, - ItemFindByIdSuccessAction, - ItemFindByIdErrorAction -} from "./item-find-single.actions"; -import { DSpaceRESTV2Response } from "../../dspace-rest-v2/dspace-rest-v2-response.model"; -import { DSpaceRESTv2Serializer } from "../../dspace-rest-v2/dspace-rest-v2.serializer"; -import { DSpaceRESTv2Service } from "../../dspace-rest-v2/dspace-rest-v2.service"; -import { CacheService } from "../cache/cache.service"; -import { GlobalConfig } from "../../../../config"; - - -@Injectable() -export class ItemDataEffects { - constructor( - private actions$: Actions, - private restApi: DSpaceRESTv2Service, - private cache: CacheService - ) {} - - // TODO, results of a findall aren't retrieved from cache for now, - // because currently the cache is more of an object store. We need to move - // more towards memoization for things like this. - @Effect() findAll$ = this.actions$ - .ofType(ItemFindMultipleActionTypes.FIND_MULTI_REQUEST) - .switchMap(() => { - return this.restApi.get('/items') - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserializeArray(data)) - .do((items: Item[]) => { - items.forEach((item) => { - this.cache.add(item, GlobalConfig.cache.msToLive); - }); - }) - .map((items: Array) => items.map(item => item.uuid)) - .map((uuids: Array) => new ItemFindMultipleSuccessAction(uuids)) - .catch((errorMsg: string) => Observable.of(new ItemFindMultipleErrorAction(errorMsg))); - }); - - @Effect() findById$ = this.actions$ - .ofType(ItemFindSingleActionTypes.FIND_BY_ID_REQUEST) - .switchMap(action => { - return this.restApi.get(`/items/${action.payload}`) - .map((data: DSpaceRESTV2Response) => new DSpaceRESTv2Serializer(Item).deserialize(data)) - .do((item: Item) => { - this.cache.add(item, GlobalConfig.cache.msToLive); - }) - .map((item: Item) => new ItemFindByIdSuccessAction(item.uuid)) - .catch((errorMsg: string) => Observable.of(new ItemFindByIdErrorAction(errorMsg))); - }); - -} diff --git a/src/app/core/data-services/item/item-data.reducer.ts b/src/app/core/data-services/item/item-data.reducer.ts deleted file mode 100644 index 20c0d805e5..0000000000 --- a/src/app/core/data-services/item/item-data.reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { combineReducers } from "@ngrx/store"; -import { ItemFindMultipleState, findMultipleReducer } from "./item-find-multiple.reducer"; -import { ItemFindSingleState, findSingleReducer } from "./item-find-single.reducer"; - -export interface ItemDataState { - findMultiple: ItemFindMultipleState, - findSingle: ItemFindSingleState -} - -const reducers = { - findMultiple: findMultipleReducer, - findSingle: findSingleReducer -}; - -export function itemDataReducer(state: any, action: any) { - return combineReducers(reducers)(state, action); -} diff --git a/src/app/core/data-services/item/item-data.service.ts b/src/app/core/data-services/item/item-data.service.ts deleted file mode 100644 index 36fa5a91db..0000000000 --- a/src/app/core/data-services/item/item-data.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from "@angular/core"; -import { Observable } from "rxjs"; -import { ItemDataState } from "./item-data.reducer"; -import { Store } from "@ngrx/store"; -import { Item } from "../../shared/item.model"; -import { ItemFindMultipleRequestAction } from "./item-find-multiple.actions"; -import { ItemFindByIdRequestAction } from "./item-find-single.actions"; -import { CacheService } from "../cache/cache.service"; -import 'rxjs/add/observable/forkJoin'; - -@Injectable() -export class ItemDataService { - constructor( - private store: Store, - private cache: CacheService - ) { } - - findAll(scopeID?: string): Observable { - this.store.dispatch(new ItemFindMultipleRequestAction(scopeID)); - //get an observable of the IDs from the itemData store - return this.store.select>('core', 'itemData', 'findMultiple', 'itemUUIDs') - .flatMap((itemUUIDs: Array) => { - // use those IDs to fetch the actual item objects from the cache - return this.cache.getList(itemUUIDs); - }); - } - - findById(id: string): Observable { - this.store.dispatch(new ItemFindByIdRequestAction(id)); - return this.store.select('core', 'itemData', 'findSingle', 'itemUUID') - .flatMap((itemUUID: string) => { - return this.cache.get(itemUUID); - }); - } - -} diff --git a/src/app/core/data-services/item/item-find-multiple.actions.ts b/src/app/core/data-services/item/item-find-multiple.actions.ts deleted file mode 100644 index 72649dba85..0000000000 --- a/src/app/core/data-services/item/item-find-multiple.actions.ts +++ /dev/null @@ -1,54 +0,0 @@ -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"; - -export const ItemFindMultipleActionTypes = { - FIND_MULTI_REQUEST: type('dspace/core/data/item/FIND_MULTI_REQUEST'), - FIND_MULTI_SUCCESS: type('dspace/core/data/item/FIND_MULTI_SUCCESS'), - FIND_MULTI_ERROR: type('dspace/core/data/item/FIND_MULTI_ERROR') -}; - -export class ItemFindMultipleRequestAction implements Action { - type = ItemFindMultipleActionTypes.FIND_MULTI_REQUEST; - payload: { - scopeID: string, - paginationOptions: PaginationOptions, - sortOptions: SortOptions - }; - - constructor( - scopeID?: string, - paginationOptions: PaginationOptions = new PaginationOptions(), - sortOptions: SortOptions = new SortOptions() - ) { - this.payload = { - scopeID, - paginationOptions, - sortOptions - } - } -} - -export class ItemFindMultipleSuccessAction implements Action { - type = ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS; - payload: Array; - - constructor(itemUUIDs: Array) { - this.payload = itemUUIDs; - } -} - -export class ItemFindMultipleErrorAction implements Action { - type = ItemFindMultipleActionTypes.FIND_MULTI_ERROR; - payload: string; - - constructor(errorMessage: string) { - this.payload = errorMessage; - } -} - -export type ItemFindMultipleAction - = ItemFindMultipleRequestAction - | ItemFindMultipleSuccessAction - | ItemFindMultipleErrorAction; diff --git a/src/app/core/data-services/item/item-find-multiple.reducer.ts b/src/app/core/data-services/item/item-find-multiple.reducer.ts deleted file mode 100644 index bb3431b1fc..0000000000 --- a/src/app/core/data-services/item/item-find-multiple.reducer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { PaginationOptions } from "../../shared/pagination-options.model"; -import { SortOptions } from "../../shared/sort-options.model"; -import { - ItemFindMultipleAction, - ItemFindMultipleActionTypes -} from "./item-find-multiple.actions"; - -export interface ItemFindMultipleState { - scopeID: string; - itemUUIDs: Array; - isLoading: boolean; - errorMessage: string; - paginationOptions: PaginationOptions; - sortOptions: SortOptions; -} - -const initialState: ItemFindMultipleState = { - scopeID: undefined, - itemUUIDs: [], - isLoading: false, - errorMessage: undefined, - paginationOptions: undefined, - sortOptions: undefined -}; - -export const findMultipleReducer = (state = initialState, action: ItemFindMultipleAction): ItemFindMultipleState => { - switch (action.type) { - - case ItemFindMultipleActionTypes.FIND_MULTI_REQUEST: { - return Object.assign({}, state, { - scopeID: action.payload.scopeID, - itemUUIDs: [], - isLoading: true, - errorMessage: undefined, - paginationOptions: action.payload.paginationOptions, - sortOptions: action.payload.sortOptions - }); - } - - case ItemFindMultipleActionTypes.FIND_MULTI_SUCCESS: { - return Object.assign({}, state, { - isLoading: false, - itemUUIDs: action.payload, - errorMessage: undefined - }); - } - - case ItemFindMultipleActionTypes.FIND_MULTI_ERROR: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: action.payload - }); - } - - default: { - return state; - } - } -}; diff --git a/src/app/core/data-services/item/item-find-single.actions.ts b/src/app/core/data-services/item/item-find-single.actions.ts deleted file mode 100644 index f514d83474..0000000000 --- a/src/app/core/data-services/item/item-find-single.actions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Action } from "@ngrx/store"; -import { type } from "../../../shared/ngrx/type"; -import { Item } from "../../shared/item.model"; - -export const ItemFindSingleActionTypes = { - FIND_BY_ID_REQUEST: type('dspace/core/data/item/FIND_BY_ID_REQUEST'), - FIND_BY_ID_SUCCESS: type('dspace/core/data/item/FIND_BY_ID_SUCCESS'), - FIND_BY_ID_ERROR: type('dspace/core/data/item/FIND_BY_ID_ERROR') -}; - -export class ItemFindByIdRequestAction implements Action { - type = ItemFindSingleActionTypes.FIND_BY_ID_REQUEST; - payload: string; - - constructor(requestID: string) { - this.payload = requestID; - } -} - -export class ItemFindByIdSuccessAction implements Action { - type = ItemFindSingleActionTypes.FIND_BY_ID_SUCCESS; - payload: string; - - constructor(itemUUID: string) { - this.payload = itemUUID; - } -} - -export class ItemFindByIdErrorAction implements Action { - type = ItemFindSingleActionTypes.FIND_BY_ID_ERROR; - payload: string; - - constructor(errorMessage: string) { - this.payload = errorMessage; - } -} - -export type ItemFindSingleAction - = ItemFindByIdRequestAction - | ItemFindByIdSuccessAction - | ItemFindByIdErrorAction; - diff --git a/src/app/core/data-services/item/item-find-single.reducer.ts b/src/app/core/data-services/item/item-find-single.reducer.ts deleted file mode 100644 index 83382728de..0000000000 --- a/src/app/core/data-services/item/item-find-single.reducer.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Item } from "../../shared/item.model"; -import { - ItemFindSingleAction, - ItemFindSingleActionTypes -} from "./item-find-single.actions"; - -export interface ItemFindSingleState { - isLoading: boolean; - errorMessage: string; - requestedID: string; - itemUUID: string; -} - -const initialState: ItemFindSingleState = { - isLoading: false, - errorMessage: undefined, - requestedID: undefined, - itemUUID: undefined -}; - -export const findSingleReducer = (state = initialState, action: ItemFindSingleAction): ItemFindSingleState => { - switch (action.type) { - - case ItemFindSingleActionTypes.FIND_BY_ID_REQUEST: { - return Object.assign({}, state, { - isLoading: true, - errorMessage: undefined, - requestedID: action.payload - }); - } - - case ItemFindSingleActionTypes.FIND_BY_ID_SUCCESS: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: undefined, - itemUUID: action.payload - }); - } - - case ItemFindSingleActionTypes.FIND_BY_ID_ERROR: { - return Object.assign({}, state, { - isLoading: false, - errorMessage: action.payload - }); - } - - default: { - return state; - } - } -}; diff --git a/src/app/core/shared/param-hash.spec.ts b/src/app/core/shared/param-hash.spec.ts new file mode 100644 index 0000000000..f532c15235 --- /dev/null +++ b/src/app/core/shared/param-hash.spec.ts @@ -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); + }); +}); diff --git a/src/app/core/shared/param-hash.ts b/src/app/core/shared/param-hash.ts new file mode 100644 index 0000000000..9d07819ce5 --- /dev/null +++ b/src/app/core/shared/param-hash.ts @@ -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; + + 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(); + } +} From bb5575cd2783ca670b908fb158b9b120a64be6c2 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 22 Feb 2017 12:03:22 +0100 Subject: [PATCH 09/21] Refactored Data Services --- src/app/core/cache/cache.reducers.ts | 17 ++++++ src/app/core/cache/object-cache.actions.ts | 33 +++++++++++ src/app/core/cache/object-cache.reducer.ts | 58 +++++++++++++++++++ .../object-cache.service.ts} | 24 ++++---- .../request-cache.actions.ts} | 36 ++++++------ .../request-cache.reducer.ts} | 38 ++++++------ src/app/core/core.module.ts | 4 +- src/app/core/core.reducers.ts | 5 +- .../core/data-services/cache/cache.actions.ts | 33 ----------- .../core/data-services/cache/cache.reducer.ts | 58 ------------------- .../data-services/collection-data.effects.ts | 10 ++-- .../data-services/collection-data.service.ts | 8 +-- src/app/core/data-services/data.effects.ts | 40 ++++++------- src/app/core/data-services/data.service.ts | 24 ++++---- .../core/data-services/item-data.effects.ts | 10 ++-- .../core/data-services/item-data.service.ts | 8 +-- src/app/core/shared/dspace-object.model.ts | 2 +- src/backend/api.ts | 8 +-- 18 files changed, 215 insertions(+), 201 deletions(-) create mode 100644 src/app/core/cache/cache.reducers.ts create mode 100644 src/app/core/cache/object-cache.actions.ts create mode 100644 src/app/core/cache/object-cache.reducer.ts rename src/app/core/{data-services/cache/cache.service.ts => cache/object-cache.service.ts} (54%) rename src/app/core/{data-services/data.actions.ts => cache/request-cache.actions.ts} (61%) rename src/app/core/{data-services/data.reducer.ts => cache/request-cache.reducer.ts} (57%) delete mode 100644 src/app/core/data-services/cache/cache.actions.ts delete mode 100644 src/app/core/data-services/cache/cache.reducer.ts diff --git a/src/app/core/cache/cache.reducers.ts b/src/app/core/cache/cache.reducers.ts new file mode 100644 index 0000000000..2edd1e8ebf --- /dev/null +++ b/src/app/core/cache/cache.reducers.ts @@ -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); +} diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts new file mode 100644 index 0000000000..1053ea66ed --- /dev/null +++ b/src/app/core/cache/object-cache.actions.ts @@ -0,0 +1,33 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../shared/ngrx/type"; +import { CacheableObject } from "./object-cache.reducer"; + +export const ObjectCacheActionTypes = { + ADD: type('dspace/core/cache/object/ADD'), + REMOVE: type('dspace/core/cache/object/REMOVE') +}; + +export class AddToObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD; + payload: { + objectToCache: CacheableObject; + msToLive: number; + }; + + constructor(objectToCache: CacheableObject, msToLive: number) { + this.payload = { objectToCache, msToLive }; + } +} + +export class RemoveFromObjectCacheAction implements Action { + type = ObjectCacheActionTypes.REMOVE; + payload: string; + + constructor(uuid: string) { + this.payload = uuid; + } +} + +export type ObjectCacheAction + = AddToObjectCacheAction + | RemoveFromObjectCacheAction diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts new file mode 100644 index 0000000000..9e3b6f50e9 --- /dev/null +++ b/src/app/core/cache/object-cache.reducer.ts @@ -0,0 +1,58 @@ +import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; +import { hasValue } from "../../shared/empty.util"; + +export interface CacheableObject { + uuid: string; +} + +export interface ObjectCacheEntry { + data: CacheableObject; + timeAdded: number; + msToLive: number; +} + +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); + +export const objectCacheReducer = (state = initialState, action: ObjectCacheAction): ObjectCacheState => { + switch (action.type) { + + case ObjectCacheActionTypes.ADD: { + return addToObjectCache(state, action); + } + + case ObjectCacheActionTypes.REMOVE: { + return removeFromObjectCache(state, action) + } + + default: { + return state; + } + } +}; + +function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { + return Object.assign({}, state, { + [action.payload.objectToCache.uuid]: { + data: action.payload.objectToCache, + timeAdded: new Date().getTime(), + msToLive: action.payload.msToLive + } + }); +} + +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; + } +} diff --git a/src/app/core/data-services/cache/cache.service.ts b/src/app/core/cache/object-cache.service.ts similarity index 54% rename from src/app/core/data-services/cache/cache.service.ts rename to src/app/core/cache/object-cache.service.ts index d8c73ec1cb..ca5d0ec658 100644 --- a/src/app/core/data-services/cache/cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,28 +1,28 @@ import { Injectable } from "@angular/core"; import { Store } from "@ngrx/store"; -import { CacheState, CacheEntry, CacheableObject } from "./cache.reducer"; -import { AddToCacheAction, RemoveFromCacheAction } from "./cache.actions"; +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 { hasNoValue } from "../../shared/empty.util"; @Injectable() -export class CacheService { +export class ObjectCacheService { constructor( - private store: Store + private store: Store ) {} add(objectToCache: CacheableObject, msToLive: number): void { - this.store.dispatch(new AddToCacheAction(objectToCache, msToLive)); + this.store.dispatch(new AddToObjectCacheAction(objectToCache, msToLive)); } remove(uuid: string): void { - this.store.dispatch(new RemoveFromCacheAction(uuid)); + this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); } get(uuid: string): Observable { - return this.store.select('core', 'cache', uuid) + return this.store.select('core', 'cache', 'object', uuid) .filter(entry => this.isValid(entry)) - .map((entry: CacheEntry) => entry.data); + .map((entry: ObjectCacheEntry) => entry.data); } getList(uuids: Array): Observable> { @@ -34,14 +34,14 @@ export class CacheService { has(uuid: string): boolean { let result: boolean; - this.store.select('core', 'cache', uuid) + this.store.select('core', 'cache', 'object', uuid) .take(1) .subscribe(entry => result = this.isValid(entry)); return result; } - private isValid(entry: CacheEntry): boolean { + private isValid(entry: ObjectCacheEntry): boolean { if (hasNoValue(entry)) { return false; } @@ -49,7 +49,7 @@ export class CacheService { const timeOutdated = entry.timeAdded + entry.msToLive; const isOutDated = new Date().getTime() > timeOutdated; if (isOutDated) { - this.store.dispatch(new RemoveFromCacheAction(entry.data.uuid)); + this.store.dispatch(new RemoveFromObjectCacheAction(entry.data.uuid)); } return !isOutDated; } diff --git a/src/app/core/data-services/data.actions.ts b/src/app/core/cache/request-cache.actions.ts similarity index 61% rename from src/app/core/data-services/data.actions.ts rename to src/app/core/cache/request-cache.actions.ts index caf37331b0..014bc1b059 100644 --- a/src/app/core/data-services/data.actions.ts +++ b/src/app/core/cache/request-cache.actions.ts @@ -4,15 +4,15 @@ import { type } from "../../shared/ngrx/type"; import { PaginationOptions } from "../shared/pagination-options.model"; import { SortOptions } from "../shared/sort-options.model"; -export const DataActionTypes = { - FIND_BY_ID_REQUEST: type('dspace/core/data/FIND_BY_ID_REQUEST'), - FIND_ALL_REQUEST: type('dspace/core/data/FIND_ALL_REQUEST'), - SUCCESS: type('dspace/core/data/SUCCESS'), - ERROR: type('dspace/core/data/ERROR') +export const RequestCacheActionTypes = { + FIND_BY_ID_REQUEST: type('dspace/core/cache/request/FIND_BY_ID_REQUEST'), + FIND_ALL_REQUEST: type('dspace/core/cache/request/FIND_ALL_REQUEST'), + SUCCESS: type('dspace/core/cache/request/SUCCESS'), + ERROR: type('dspace/core/cache/request/ERROR') }; -export class DataFindAllRequestAction implements Action { - type = DataActionTypes.FIND_ALL_REQUEST; +export class FindAllRequestCacheAction implements Action { + type = RequestCacheActionTypes.FIND_ALL_REQUEST; payload: { key: string, service: OpaqueToken, @@ -38,8 +38,8 @@ export class DataFindAllRequestAction implements Action { } } -export class DataFindByIDRequestAction implements Action { - type = DataActionTypes.FIND_BY_ID_REQUEST; +export class FindByIDRequestCacheAction implements Action { + type = RequestCacheActionTypes.FIND_BY_ID_REQUEST; payload: { key: string, service: OpaqueToken, @@ -59,8 +59,8 @@ export class DataFindByIDRequestAction implements Action { } } -export class DataSuccessAction implements Action { - type = DataActionTypes.SUCCESS; +export class RequestCacheSuccessAction implements Action { + type = RequestCacheActionTypes.SUCCESS; payload: { key: string, resourceUUIDs: Array @@ -74,8 +74,8 @@ export class DataSuccessAction implements Action { } } -export class DataErrorAction implements Action { - type = DataActionTypes.ERROR; +export class RequestCacheErrorAction implements Action { + type = RequestCacheActionTypes.ERROR; payload: { key: string, errorMessage: string @@ -89,8 +89,8 @@ export class DataErrorAction implements Action { } } -export type DataAction - = DataFindAllRequestAction - | DataFindByIDRequestAction - | DataSuccessAction - | DataErrorAction; +export type RequestCacheAction + = FindAllRequestCacheAction + | FindByIDRequestCacheAction + | RequestCacheSuccessAction + | RequestCacheErrorAction; diff --git a/src/app/core/data-services/data.reducer.ts b/src/app/core/cache/request-cache.reducer.ts similarity index 57% rename from src/app/core/data-services/data.reducer.ts rename to src/app/core/cache/request-cache.reducer.ts index 3641e790c7..320bf6251d 100644 --- a/src/app/core/data-services/data.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -1,12 +1,12 @@ import { PaginationOptions } from "../shared/pagination-options.model"; import { SortOptions } from "../shared/sort-options.model"; import { - DataAction, DataActionTypes, DataFindAllRequestAction, - DataSuccessAction, DataErrorAction, DataFindByIDRequestAction -} from "./data.actions"; + RequestCacheAction, RequestCacheActionTypes, FindAllRequestCacheAction, + RequestCacheSuccessAction, RequestCacheErrorAction, FindByIDRequestCacheAction +} from "./request-cache.actions"; import { OpaqueToken } from "@angular/core"; -export interface DataRequestState { +export interface CachedRequest { service: OpaqueToken scopeID: string; resourceID: string; @@ -20,30 +20,30 @@ export interface DataRequestState { msToLive: number; } -export interface DataState { - [key: string]: DataRequestState +export interface RequestCacheState { + [key: string]: CachedRequest } // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) const initialState = Object.create(null); -export const dataReducer = (state = initialState, action: DataAction): DataState => { +export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => { switch (action.type) { - case DataActionTypes.FIND_ALL_REQUEST: { - return findAllRequest(state, action); + case RequestCacheActionTypes.FIND_ALL_REQUEST: { + return findAllRequest(state, action); } - case DataActionTypes.FIND_BY_ID_REQUEST: { - return findByIDRequest(state, action); + case RequestCacheActionTypes.FIND_BY_ID_REQUEST: { + return findByIDRequest(state, action); } - case DataActionTypes.SUCCESS: { - return success(state, action); + case RequestCacheActionTypes.SUCCESS: { + return success(state, action); } - case DataActionTypes.ERROR: { - return error(state, action); + case RequestCacheActionTypes.ERROR: { + return error(state, action); } default: { @@ -52,7 +52,7 @@ export const dataReducer = (state = initialState, action: DataAction): DataState } }; -function findAllRequest(state: DataState, action: DataFindAllRequestAction): DataState { +function findAllRequest(state: RequestCacheState, action: FindAllRequestCacheAction): RequestCacheState { return Object.assign({}, state, { [action.payload.key]: { service: action.payload.service, @@ -66,7 +66,7 @@ function findAllRequest(state: DataState, action: DataFindAllRequestAction): Dat }); } -function findByIDRequest(state: DataState, action: DataFindByIDRequestAction): DataState { +function findByIDRequest(state: RequestCacheState, action: FindByIDRequestCacheAction): RequestCacheState { return Object.assign({}, state, { [action.payload.key]: { service: action.payload.service, @@ -78,7 +78,7 @@ function findByIDRequest(state: DataState, action: DataFindByIDRequestAction): D }); } -function success(state: DataState, action: DataSuccessAction): DataState { +function success(state: RequestCacheState, action: RequestCacheSuccessAction): RequestCacheState { return Object.assign({}, state, { [action.payload.key]: Object.assign({}, state[action.payload.key], { isLoading: false, @@ -88,7 +88,7 @@ function success(state: DataState, action: DataSuccessAction): DataState { }); } -function error(state: DataState, action: DataErrorAction): DataState { +function error(state: RequestCacheState, action: RequestCacheErrorAction): RequestCacheState { return Object.assign({}, state, { [action.payload.key]: Object.assign({}, state[action.payload.key], { isLoading: false, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 847f6f8287..a2fd79ffbc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -4,7 +4,7 @@ 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 { CacheService } from "./data-services/cache/cache.service"; +import { ObjectCacheService } from "./cache/object-cache.service"; import { CollectionDataService } from "./data-services/collection-data.service"; import { ItemDataService } from "./data-services/item-data.service"; @@ -25,7 +25,7 @@ const PROVIDERS = [ CollectionDataService, ItemDataService, DSpaceRESTv2Service, - CacheService + ObjectCacheService ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index d39039c499..71f25ee0b0 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,14 +1,11 @@ import { combineReducers } from "@ngrx/store"; -import { CacheState, cacheReducer } from "./data-services/cache/cache.reducer"; -import { dataReducer, DataState } from "./data-services/data.reducer"; +import { CacheState, cacheReducer } from "./cache/cache.reducers"; export interface CoreState { - data: DataState, cache: CacheState } export const reducers = { - data: dataReducer, cache: cacheReducer }; diff --git a/src/app/core/data-services/cache/cache.actions.ts b/src/app/core/data-services/cache/cache.actions.ts deleted file mode 100644 index 43146f5ce9..0000000000 --- a/src/app/core/data-services/cache/cache.actions.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Action } from "@ngrx/store"; -import { type } from "../../../shared/ngrx/type"; -import { CacheableObject } from "./cache.reducer"; - -export const CacheActionTypes = { - ADD: type('dspace/core/data/cache/ADD'), - REMOVE: type('dspace/core/data/cache/REMOVE') -}; - -export class AddToCacheAction implements Action { - type = CacheActionTypes.ADD; - payload: { - objectToCache: CacheableObject; - msToLive: number; - }; - - constructor(objectToCache: CacheableObject, msToLive: number) { - this.payload = { objectToCache, msToLive }; - } -} - -export class RemoveFromCacheAction implements Action { - type = CacheActionTypes.REMOVE; - payload: string; - - constructor(uuid: string) { - this.payload = uuid; - } -} - -export type CacheAction - = AddToCacheAction - | RemoveFromCacheAction diff --git a/src/app/core/data-services/cache/cache.reducer.ts b/src/app/core/data-services/cache/cache.reducer.ts deleted file mode 100644 index 59b567e939..0000000000 --- a/src/app/core/data-services/cache/cache.reducer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { CacheAction, CacheActionTypes, AddToCacheAction, RemoveFromCacheAction } from "./cache.actions"; -import { hasValue } from "../../../shared/empty.util"; - -export interface CacheableObject { - uuid: string; -} - -export interface CacheEntry { - data: CacheableObject; - timeAdded: number; - msToLive: number; -} - -export interface CacheState { - [uuid: string]: CacheEntry -} - -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState: CacheState = Object.create(null); - -export const cacheReducer = (state = initialState, action: CacheAction): CacheState => { - switch (action.type) { - - case CacheActionTypes.ADD: { - return addToCache(state, action); - } - - case CacheActionTypes.REMOVE: { - return removeFromCache(state, action) - } - - default: { - return state; - } - } -}; - -function addToCache(state: CacheState, action: AddToCacheAction): CacheState { - return Object.assign({}, state, { - [action.payload.objectToCache.uuid]: { - data: action.payload.objectToCache, - timeAdded: new Date().getTime(), - msToLive: action.payload.msToLive - } - }); -} - -function removeFromCache(state: CacheState, action: RemoveFromCacheAction): CacheState { - if (hasValue(state[action.payload])) { - let newCache = Object.assign({}, state); - delete newCache[action.payload]; - - return newCache; - } - else { - return state; - } -} diff --git a/src/app/core/data-services/collection-data.effects.ts b/src/app/core/data-services/collection-data.effects.ts index 9daacf999d..03bec274bc 100644 --- a/src/app/core/data-services/collection-data.effects.ts +++ b/src/app/core/data-services/collection-data.effects.ts @@ -3,10 +3,10 @@ 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 { CacheService } from "./cache/cache.service"; +import { ObjectCacheService } from "../cache/object-cache.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { Actions, Effect } from "@ngrx/effects"; -import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { CollectionDataService } from "./collection-data.service"; @Injectable() @@ -14,17 +14,17 @@ export class CollectionDataEffects extends DataEffects { constructor( actions$: Actions, restApi: DSpaceRESTv2Service, - cache: CacheService, + cache: ObjectCacheService, dataService: CollectionDataService ) { super(actions$, restApi, cache, dataService); } - protected getFindAllEndpoint(action: DataFindAllRequestAction): string { + protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { return '/collections'; } - protected getFindByIdEndpoint(action: DataFindByIDRequestAction): string { + protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { return `/collections/${action.payload.resourceID}`; } diff --git a/src/app/core/data-services/collection-data.service.ts b/src/app/core/data-services/collection-data.service.ts index 9f4e2ed902..fdfaaa97ac 100644 --- a/src/app/core/data-services/collection-data.service.ts +++ b/src/app/core/data-services/collection-data.service.ts @@ -2,16 +2,16 @@ import { Injectable, OpaqueToken } from "@angular/core"; import { Store } from "@ngrx/store"; import { DataService } from "./data.service"; import { Collection } from "../shared/collection.model"; -import { CacheService } from "./cache/cache.service"; -import { DataState } from "./data.reducer"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { RequestCacheState } from "../cache/request-cache.reducer"; @Injectable() export class CollectionDataService extends DataService { name = new OpaqueToken('CollectionDataService'); constructor( - store: Store, - cache: CacheService + store: Store, + cache: ObjectCacheService ) { super(store, cache); } diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts index 362471bdfb..aaafda598b 100644 --- a/src/app/core/data-services/data.effects.ts +++ b/src/app/core/data-services/data.effects.ts @@ -2,25 +2,25 @@ import { Actions, Effect } 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 { CacheService } from "./cache/cache.service"; +import { ObjectCacheService } from "../cache/object-cache.service"; import { GlobalConfig } from "../../../config"; -import { CacheableObject } from "./cache/cache.reducer"; +import { CacheableObject } from "../cache/object-cache.reducer"; import { Serializer } from "../serializer"; import { - DataActionTypes, DataFindAllRequestAction, DataSuccessAction, - DataErrorAction, DataFindByIDRequestAction, DataAction -} from "./data.actions"; + RequestCacheActionTypes, FindAllRequestCacheAction, RequestCacheSuccessAction, + RequestCacheErrorAction, FindByIDRequestCacheAction +} from "../cache/request-cache.actions"; import { DataService } from "./data.service"; export abstract class DataEffects { - protected abstract getFindAllEndpoint(action: DataFindAllRequestAction): string; - protected abstract getFindByIdEndpoint(action: DataFindByIDRequestAction): string; + protected abstract getFindAllEndpoint(action: FindAllRequestCacheAction): string; + protected abstract getFindByIdEndpoint(action: FindByIDRequestCacheAction): string; protected abstract getSerializer(): Serializer; constructor( private actions$: Actions, private restApi: DSpaceRESTv2Service, - private cache: CacheService, + private objectCache: ObjectCacheService, private dataService: DataService ) {} @@ -28,33 +28,33 @@ export abstract class DataEffects { // because currently the cache is more of an object store. We need to move // more towards memoization for things like this. protected findAll = this.actions$ - .ofType(DataActionTypes.FIND_ALL_REQUEST) - .filter((action: DataFindAllRequestAction) => action.payload.service === this.dataService.name) - .switchMap((action: DataFindAllRequestAction) => { + .ofType(RequestCacheActionTypes.FIND_ALL_REQUEST) + .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) + .switchMap((action: FindAllRequestCacheAction) => { //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) => { - this.cache.add(t, GlobalConfig.cache.msToLive); + this.objectCache.add(t, GlobalConfig.cache.msToLive); }); }) .map((ts: Array) => ts.map(t => t.uuid)) - .map((ids: Array) => new DataSuccessAction(action.payload.key, ids)) - .catch((errorMsg: string) => Observable.of(new DataErrorAction(action.payload.key, errorMsg))); + .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids)) + .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); protected findById = this.actions$ - .ofType(DataActionTypes.FIND_BY_ID_REQUEST) - .filter((action: DataFindAllRequestAction) => action.payload.service === this.dataService.name) - .switchMap((action: DataFindByIDRequestAction) => { + .ofType(RequestCacheActionTypes.FIND_BY_ID_REQUEST) + .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) + .switchMap((action: FindByIDRequestCacheAction) => { return this.restApi.get(this.getFindByIdEndpoint(action)) .map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data)) .do((t: T) => { - this.cache.add(t, GlobalConfig.cache.msToLive); + this.objectCache.add(t, GlobalConfig.cache.msToLive); }) - .map((t: T) => new DataSuccessAction(action.payload.key, [t.uuid])) - .catch((errorMsg: string) => Observable.of(new DataErrorAction(action.payload.key, errorMsg))); + .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid])) + .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); } diff --git a/src/app/core/data-services/data.service.ts b/src/app/core/data-services/data.service.ts index 26a9e4e377..120245c769 100644 --- a/src/app/core/data-services/data.service.ts +++ b/src/app/core/data-services/data.service.ts @@ -1,10 +1,10 @@ import { OpaqueToken } from "@angular/core"; import { Observable } from "rxjs"; import { Store } from "@ngrx/store"; -import { CacheService } from "./cache/cache.service"; -import { CacheableObject } from "./cache/cache.reducer"; -import { DataState } from "./data.reducer"; -import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { RequestCacheState } from "../cache/request-cache.reducer"; +import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { ParamHash } from "../shared/param-hash"; import { isNotEmpty } from "../../shared/empty.util"; @@ -12,28 +12,28 @@ export abstract class DataService { abstract name: OpaqueToken; constructor( - private store: Store, - private cache: CacheService + private store: Store, + private objectCache: ObjectCacheService ) { } findAll(scopeID?: string): Observable> { const key = new ParamHash(this.name, 'findAll', scopeID).toString(); - this.store.dispatch(new DataFindAllRequestAction(key, this.name, scopeID)); + this.store.dispatch(new FindAllRequestCacheAction(key, this.name, scopeID)); //get an observable of the IDs from the store - return this.store.select>('core', 'data', key, 'resourceUUIDs') + return this.store.select>('core', 'cache', 'request', key, 'resourceUUIDs') .flatMap((resourceUUIDs: Array) => { // use those IDs to fetch the actual objects from the cache - return this.cache.getList(resourceUUIDs); + return this.objectCache.getList(resourceUUIDs); }); } findById(id: string): Observable { const key = new ParamHash(this.name, 'findById', id).toString(); - this.store.dispatch(new DataFindByIDRequestAction(key, this.name, id)); - return this.store.select>('core', 'data', key, 'resourceUUIDs') + this.store.dispatch(new FindByIDRequestCacheAction(key, this.name, id)); + return this.store.select>('core', 'cache', 'request', key, 'resourceUUIDs') .flatMap((resourceUUIDs: Array) => { if(isNotEmpty(resourceUUIDs)) { - return this.cache.get(resourceUUIDs[0]); + return this.objectCache.get(resourceUUIDs[0]); } else { return Observable.of(undefined); diff --git a/src/app/core/data-services/item-data.effects.ts b/src/app/core/data-services/item-data.effects.ts index 3976691e48..c1b8fb5ef2 100644 --- a/src/app/core/data-services/item-data.effects.ts +++ b/src/app/core/data-services/item-data.effects.ts @@ -3,10 +3,10 @@ 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 { CacheService } from "./cache/cache.service"; +import { ObjectCacheService } from "../cache/object-cache.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { Actions, Effect } from "@ngrx/effects"; -import { DataFindAllRequestAction, DataFindByIDRequestAction } from "./data.actions"; +import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { ItemDataService } from "./item-data.service"; @Injectable() @@ -14,17 +14,17 @@ export class ItemDataEffects extends DataEffects { constructor( actions$: Actions, restApi: DSpaceRESTv2Service, - cache: CacheService, + cache: ObjectCacheService, dataService: ItemDataService ) { super(actions$, restApi, cache, dataService); } - protected getFindAllEndpoint(action: DataFindAllRequestAction): string { + protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { return '/items'; } - protected getFindByIdEndpoint(action: DataFindByIDRequestAction): string { + protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { return `/items/${action.payload.resourceID}`; } diff --git a/src/app/core/data-services/item-data.service.ts b/src/app/core/data-services/item-data.service.ts index 2d7128289a..86b6bf9678 100644 --- a/src/app/core/data-services/item-data.service.ts +++ b/src/app/core/data-services/item-data.service.ts @@ -2,16 +2,16 @@ import { Injectable, OpaqueToken } from "@angular/core"; import { Store } from "@ngrx/store"; import { DataService } from "./data.service"; import { Item } from "../shared/item.model"; -import { CacheService } from "./cache/cache.service"; -import { DataState } from "./data.reducer"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { RequestCacheState } from "../cache/request-cache.reducer"; @Injectable() export class ItemDataService extends DataService { name = new OpaqueToken('ItemDataService'); constructor( - store: Store, - cache: CacheService + store: Store, + cache: ObjectCacheService ) { super(store, cache); } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index d9197db2b4..4318e6b80e 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,7 +1,7 @@ import { autoserialize, autoserializeAs } from "cerialize"; import { Metadatum } from "./metadatum.model" import { isEmpty, isNotEmpty } from "../../shared/empty.util"; -import { CacheableObject } from "../data-services/cache/cache.reducer"; +import { CacheableObject } from "../cache/object-cache.reducer"; /** * An abstract model class for a DSpaceObject. diff --git a/src/backend/api.ts b/src/backend/api.ts index 1b86548195..bc62629737 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -92,7 +92,7 @@ export function createMockApi() { router.route('/collections/:collection_id') .get(function(req, res) { - console.log('GET', util.inspect(req.collection.id, { colors: true })); + // console.log('GET', util.inspect(req.collection.id, { colors: true })); res.json(toHALResponse(req, req.collection)); // }) // .put(function(req, res) { @@ -154,7 +154,7 @@ export function createMockApi() { router.route('/items/:item_id') .get(function(req, res) { - console.log('GET', util.inspect(req.item, { colors: true })); + // console.log('GET', util.inspect(req.item, { colors: true })); res.json(toHALResponse(req, req.item)); // }) // .put(function(req, res) { @@ -199,7 +199,7 @@ export function createMockApi() { router.route('/bundles/:bundle_id') .get(function(req, res) { - console.log('GET', util.inspect(req.bundle, { colors: true })); + // console.log('GET', util.inspect(req.bundle, { colors: true })); res.json(toHALResponse(req, req.bundle)); }); @@ -229,7 +229,7 @@ export function createMockApi() { router.route('/bitstreams/:bitstream_id') .get(function(req, res) { - console.log('GET', util.inspect(req.bitstream, { colors: true })); + // console.log('GET', util.inspect(req.bitstream, { colors: true })); res.json(toHALResponse(req, req.bitstream)); }); From 7fb8360f10ae0d64f295a361625ec19678c69d2f Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 22 Feb 2017 12:59:11 +0100 Subject: [PATCH 10/21] RequestCache: started tracking timeAdded and timeToLive --- src/app/core/cache/request-cache.actions.ts | 8 +++++--- src/app/core/cache/request-cache.reducer.ts | 4 +++- src/app/core/data-services/data.effects.ts | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/core/cache/request-cache.actions.ts b/src/app/core/cache/request-cache.actions.ts index 014bc1b059..63874f9188 100644 --- a/src/app/core/cache/request-cache.actions.ts +++ b/src/app/core/cache/request-cache.actions.ts @@ -63,13 +63,15 @@ export class RequestCacheSuccessAction implements Action { type = RequestCacheActionTypes.SUCCESS; payload: { key: string, - resourceUUIDs: Array + resourceUUIDs: Array, + msToLive: number }; - constructor(key: string, resourceUUIDs: Array) { + constructor(key: string, resourceUUIDs: Array, msToLive: number) { this.payload = { key, - resourceUUIDs + resourceUUIDs, + msToLive }; } } diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts index 320bf6251d..051b029701 100644 --- a/src/app/core/cache/request-cache.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -83,7 +83,9 @@ function success(state: RequestCacheState, action: RequestCacheSuccessAction): R [action.payload.key]: Object.assign({}, state[action.payload.key], { isLoading: false, resourceUUIDs: action.payload.resourceUUIDs, - errorMessage: undefined + errorMessage: undefined, + timeAdded: new Date().getTime(), + msToLive: action.payload.msToLive }) }); } diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts index aaafda598b..545ef6ec45 100644 --- a/src/app/core/data-services/data.effects.ts +++ b/src/app/core/data-services/data.effects.ts @@ -40,7 +40,7 @@ export abstract class DataEffects { }); }) .map((ts: Array) => ts.map(t => t.uuid)) - .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids)) + .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, GlobalConfig.cache.msToLive)) .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); @@ -53,7 +53,7 @@ export abstract class DataEffects { .do((t: T) => { this.objectCache.add(t, GlobalConfig.cache.msToLive); }) - .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid])) + .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], GlobalConfig.cache.msToLive)) .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); From b0f25c4dae5d76ae6e502bcdce85696035ba68b7 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Fri, 24 Feb 2017 13:11:14 +0100 Subject: [PATCH 11/21] Fixed an issue where, if you called findAll or findById multiple times in a row, all network requests were cancelled except for the last one. --- src/app/core/data-services/data.effects.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts index 545ef6ec45..ee7318da12 100644 --- a/src/app/core/data-services/data.effects.ts +++ b/src/app/core/data-services/data.effects.ts @@ -30,7 +30,7 @@ export abstract class DataEffects { protected findAll = this.actions$ .ofType(RequestCacheActionTypes.FIND_ALL_REQUEST) .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) - .switchMap((action: FindAllRequestCacheAction) => { + .flatMap((action: FindAllRequestCacheAction) => { //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)) @@ -47,7 +47,7 @@ export abstract class DataEffects { protected findById = this.actions$ .ofType(RequestCacheActionTypes.FIND_BY_ID_REQUEST) .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) - .switchMap((action: FindByIDRequestCacheAction) => { + .flatMap((action: FindByIDRequestCacheAction) => { return this.restApi.get(this.getFindByIdEndpoint(action)) .map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data)) .do((t: T) => { From 7745938027e1e58b72d38a5dddb850d9b059aa5b Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 2 Mar 2017 13:36:40 +0100 Subject: [PATCH 12/21] More refactoring + reusing the server's store on the client --- src/app/app.module.ts | 35 -------- src/app/app.reducers.ts | 6 ++ src/app/core/cache/cache-entry.ts | 4 + src/app/core/cache/object-cache.actions.ts | 5 +- src/app/core/cache/object-cache.reducer.ts | 5 +- src/app/core/cache/object-cache.service.ts | 12 +-- src/app/core/cache/request-cache.actions.ts | 35 +++++--- src/app/core/cache/request-cache.reducer.ts | 48 +++++++--- src/app/core/cache/request-cache.service.ts | 73 +++++++++++++++ src/app/core/core.module.ts | 4 +- .../data-services/collection-data.effects.ts | 6 +- .../data-services/collection-data.service.ts | 11 ++- src/app/core/data-services/data.effects.ts | 30 +++---- src/app/core/data-services/data.service.ts | 36 ++++---- .../core/data-services/item-data.effects.ts | 6 +- .../core/data-services/item-data.service.ts | 11 ++- .../dspace-rest-v2.serializer.ts | 10 +-- src/app/core/shared/bundle.model.ts | 25 +++--- src/app/core/shared/dspace-object.model.ts | 88 +++++++++---------- src/app/core/shared/generic-constructor.ts | 7 ++ src/app/core/shared/item.model.ts | 51 +++++------ src/app/shared/demo-cache.service.ts | 88 ------------------- src/app/shared/model/model.service.ts | 55 ------------ src/app/shared/shared.module.ts | 2 - src/app/store.actions.ts | 16 ++++ src/browser.module.ts | 25 ++++-- src/node.module.ts | 25 ++++-- 27 files changed, 348 insertions(+), 371 deletions(-) create mode 100644 src/app/core/cache/cache-entry.ts create mode 100644 src/app/core/cache/request-cache.service.ts create mode 100644 src/app/core/shared/generic-constructor.ts delete mode 100644 src/app/shared/demo-cache.service.ts delete mode 100644 src/app/shared/model/model.service.ts create mode 100644 src/app/store.actions.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e65c2a2c41..bda3ccc9ea 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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: [ ] diff --git a/src/app/app.reducers.ts b/src/app/app.reducers.ts index db27ecf9a7..407a334144 100644 --- a/src/app/app.reducers.ts +++ b/src/app/app.reducers.ts @@ -3,6 +3,7 @@ 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; @@ -19,5 +20,10 @@ export const reducers = { }; 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"; diff --git a/src/app/core/cache/cache-entry.ts b/src/app/core/cache/cache-entry.ts new file mode 100644 index 0000000000..52ec7b8af0 --- /dev/null +++ b/src/app/core/cache/cache-entry.ts @@ -0,0 +1,4 @@ +export interface CacheEntry { + timeAdded: number; + msToLive: number; +} diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 1053ea66ed..21855e5170 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -11,11 +11,12 @@ export class AddToObjectCacheAction implements Action { type = ObjectCacheActionTypes.ADD; payload: { objectToCache: CacheableObject; + timeAdded: number; msToLive: number; }; - constructor(objectToCache: CacheableObject, msToLive: number) { - this.payload = { objectToCache, msToLive }; + constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number) { + this.payload = { objectToCache, timeAdded, msToLive }; } } diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 9e3b6f50e9..93e9f6ff05 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,11 +1,12 @@ import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; import { hasValue } from "../../shared/empty.util"; +import { CacheEntry } from "./cache-entry"; export interface CacheableObject { uuid: string; } -export interface ObjectCacheEntry { +export class ObjectCacheEntry implements CacheEntry { data: CacheableObject; timeAdded: number; msToLive: number; @@ -39,7 +40,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio return Object.assign({}, state, { [action.payload.objectToCache.uuid]: { data: action.payload.objectToCache, - timeAdded: new Date().getTime(), + timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive } }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index ca5d0ec658..c9d3bccf83 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -4,6 +4,7 @@ import { ObjectCacheState, ObjectCacheEntry, CacheableObject } from "./object-ca import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; import { Observable } from "rxjs"; import { hasNoValue } from "../../shared/empty.util"; +import { GenericConstructor } from "../shared/generic-constructor"; @Injectable() export class ObjectCacheService { @@ -12,22 +13,23 @@ export class ObjectCacheService { ) {} add(objectToCache: CacheableObject, msToLive: number): void { - this.store.dispatch(new AddToObjectCacheAction(objectToCache, msToLive)); + this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive)); } remove(uuid: string): void { this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); } - get(uuid: string): Observable { + get(uuid: string, ctor: GenericConstructor): Observable { return this.store.select('core', 'cache', 'object', uuid) .filter(entry => this.isValid(entry)) - .map((entry: ObjectCacheEntry) => entry.data); + .distinctUntilChanged() + .map((entry: ObjectCacheEntry) => Object.assign(new ctor(), entry.data)); } - getList(uuids: Array): Observable> { + getList(uuids: Array, ctor: GenericConstructor): Observable> { return Observable.combineLatest( - uuids.map((id: string) => this.get(id)) + uuids.map((id: string) => this.get(id, ctor)) ); } diff --git a/src/app/core/cache/request-cache.actions.ts b/src/app/core/cache/request-cache.actions.ts index 63874f9188..85d7ca7fa3 100644 --- a/src/app/core/cache/request-cache.actions.ts +++ b/src/app/core/cache/request-cache.actions.ts @@ -5,14 +5,15 @@ import { PaginationOptions } from "../shared/pagination-options.model"; import { SortOptions } from "../shared/sort-options.model"; export const RequestCacheActionTypes = { - FIND_BY_ID_REQUEST: type('dspace/core/cache/request/FIND_BY_ID_REQUEST'), - FIND_ALL_REQUEST: type('dspace/core/cache/request/FIND_ALL_REQUEST'), + 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') + ERROR: type('dspace/core/cache/request/ERROR'), + REMOVE: type('dspace/core/cache/request/REMOVE') }; -export class FindAllRequestCacheAction implements Action { - type = RequestCacheActionTypes.FIND_ALL_REQUEST; +export class RequestCacheFindAllAction implements Action { + type = RequestCacheActionTypes.FIND_ALL; payload: { key: string, service: OpaqueToken, @@ -38,8 +39,8 @@ export class FindAllRequestCacheAction implements Action { } } -export class FindByIDRequestCacheAction implements Action { - type = RequestCacheActionTypes.FIND_BY_ID_REQUEST; +export class RequestCacheFindByIDAction implements Action { + type = RequestCacheActionTypes.FIND_BY_ID; payload: { key: string, service: OpaqueToken, @@ -64,13 +65,15 @@ export class RequestCacheSuccessAction implements Action { payload: { key: string, resourceUUIDs: Array, + timeAdded: number, msToLive: number }; - constructor(key: string, resourceUUIDs: Array, msToLive: number) { + constructor(key: string, resourceUUIDs: Array, timeAdded, msToLive: number) { this.payload = { key, resourceUUIDs, + timeAdded, msToLive }; } @@ -91,8 +94,18 @@ export class RequestCacheErrorAction implements Action { } } +export class RequestCacheRemoveAction implements Action { + type = RequestCacheActionTypes.REMOVE; + payload: string; + + constructor(key: string) { + this.payload = key; + } +} + export type RequestCacheAction - = FindAllRequestCacheAction - | FindByIDRequestCacheAction + = RequestCacheFindAllAction + | RequestCacheFindByIDAction | RequestCacheSuccessAction - | RequestCacheErrorAction; + | RequestCacheErrorAction + | RequestCacheRemoveAction; diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts index 051b029701..b867dcbdf8 100644 --- a/src/app/core/cache/request-cache.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -1,13 +1,17 @@ import { PaginationOptions } from "../shared/pagination-options.model"; import { SortOptions } from "../shared/sort-options.model"; import { - RequestCacheAction, RequestCacheActionTypes, FindAllRequestCacheAction, - RequestCacheSuccessAction, RequestCacheErrorAction, FindByIDRequestCacheAction + RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction, + RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction, + RequestCacheRemoveAction } from "./request-cache.actions"; import { OpaqueToken } from "@angular/core"; +import { CacheEntry } from "./cache-entry"; +import { hasValue } from "../../shared/empty.util"; -export interface CachedRequest { - service: OpaqueToken +export class RequestCacheEntry implements CacheEntry { + service: OpaqueToken; + key: string; scopeID: string; resourceID: string; resourceUUIDs: Array; @@ -21,7 +25,7 @@ export interface CachedRequest { } export interface RequestCacheState { - [key: string]: CachedRequest + [key: string]: RequestCacheEntry } // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) @@ -30,12 +34,12 @@ const initialState = Object.create(null); export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => { switch (action.type) { - case RequestCacheActionTypes.FIND_ALL_REQUEST: { - return findAllRequest(state, action); + case RequestCacheActionTypes.FIND_ALL: { + return findAllRequest(state, action); } - case RequestCacheActionTypes.FIND_BY_ID_REQUEST: { - return findByIDRequest(state, action); + case RequestCacheActionTypes.FIND_BY_ID: { + return findByIDRequest(state, action); } case RequestCacheActionTypes.SUCCESS: { @@ -46,15 +50,21 @@ export const requestCacheReducer = (state = initialState, action: RequestCacheAc return error(state, action); } + case RequestCacheActionTypes.REMOVE: { + return removeFromCache(state, action); + } + default: { return state; } } }; -function findAllRequest(state: RequestCacheState, action: FindAllRequestCacheAction): RequestCacheState { +function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState { + console.log('break here', state); return Object.assign({}, state, { [action.payload.key]: { + key: action.payload.key, service: action.payload.service, scopeID: action.payload.scopeID, resourceUUIDs: [], @@ -66,9 +76,10 @@ function findAllRequest(state: RequestCacheState, action: FindAllRequestCacheAct }); } -function findByIDRequest(state: RequestCacheState, action: FindByIDRequestCacheAction): RequestCacheState { +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: [], @@ -84,7 +95,7 @@ function success(state: RequestCacheState, action: RequestCacheSuccessAction): R isLoading: false, resourceUUIDs: action.payload.resourceUUIDs, errorMessage: undefined, - timeAdded: new Date().getTime(), + timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive }) }); @@ -99,4 +110,17 @@ function error(state: RequestCacheState, action: RequestCacheErrorAction): Reque }); } +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; + } +} + + diff --git a/src/app/core/cache/request-cache.service.ts b/src/app/core/cache/request-cache.service.ts new file mode 100644 index 0000000000..418a1ad204 --- /dev/null +++ b/src/app/core/cache/request-cache.service.ts @@ -0,0 +1,73 @@ +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"; + +@Injectable() +export class RequestCacheService { + constructor( + private store: Store + ) {} + + findAll( + key: string, + service: OpaqueToken, + scopeID?: string, + paginationOptions?: PaginationOptions, + sortOptions?: SortOptions + ): Observable { + if (!this.has(key)) { + this.store.dispatch(new RequestCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions)); + } + return this.get(key); + } + + findById( + key: string, + service: OpaqueToken, + resourceID: string + ): Observable { + if (!this.has(key)) { + this.store.dispatch(new RequestCacheFindByIDAction(key, service, resourceID)); + } + return this.get(key); + } + + get(key: string): Observable { + return this.store.select('core', 'cache', 'request', key) + .filter(entry => this.isValid(entry)) + .distinctUntilChanged() + } + + has(key: string): boolean { + let result: boolean; + + this.store.select('core', 'cache', 'request', key) + .take(1) + .subscribe(entry => result = this.isValid(entry)); + + return result; + } + + 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; + } + } + +} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index a2fd79ffbc..5aa2b149a5 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -5,6 +5,7 @@ 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"; @@ -25,7 +26,8 @@ const PROVIDERS = [ CollectionDataService, ItemDataService, DSpaceRESTv2Service, - ObjectCacheService + ObjectCacheService, + RequestCacheService ]; @NgModule({ diff --git a/src/app/core/data-services/collection-data.effects.ts b/src/app/core/data-services/collection-data.effects.ts index 03bec274bc..676d71659c 100644 --- a/src/app/core/data-services/collection-data.effects.ts +++ b/src/app/core/data-services/collection-data.effects.ts @@ -6,7 +6,7 @@ import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.seriali import { ObjectCacheService } from "../cache/object-cache.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { Actions, Effect } from "@ngrx/effects"; -import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; +import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; import { CollectionDataService } from "./collection-data.service"; @Injectable() @@ -20,11 +20,11 @@ export class CollectionDataEffects extends DataEffects { super(actions$, restApi, cache, dataService); } - protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { + protected getFindAllEndpoint(action: RequestCacheFindAllAction): string { return '/collections'; } - protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { + protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string { return `/collections/${action.payload.resourceID}`; } diff --git a/src/app/core/data-services/collection-data.service.ts b/src/app/core/data-services/collection-data.service.ts index fdfaaa97ac..cc850900db 100644 --- a/src/app/core/data-services/collection-data.service.ts +++ b/src/app/core/data-services/collection-data.service.ts @@ -1,19 +1,18 @@ import { Injectable, OpaqueToken } from "@angular/core"; -import { Store } from "@ngrx/store"; import { DataService } from "./data.service"; import { Collection } from "../shared/collection.model"; import { ObjectCacheService } from "../cache/object-cache.service"; -import { RequestCacheState } from "../cache/request-cache.reducer"; +import { RequestCacheService } from "../cache/request-cache.service"; @Injectable() export class CollectionDataService extends DataService { - name = new OpaqueToken('CollectionDataService'); + serviceName = new OpaqueToken('CollectionDataService'); constructor( - store: Store, - cache: ObjectCacheService + protected objectCache: ObjectCacheService, + protected requestCache: RequestCacheService, ) { - super(store, cache); + super(Collection); } } diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts index ee7318da12..c39a718732 100644 --- a/src/app/core/data-services/data.effects.ts +++ b/src/app/core/data-services/data.effects.ts @@ -1,4 +1,4 @@ -import { Actions, Effect } from "@ngrx/effects"; +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"; @@ -7,14 +7,14 @@ import { GlobalConfig } from "../../../config"; import { CacheableObject } from "../cache/object-cache.reducer"; import { Serializer } from "../serializer"; import { - RequestCacheActionTypes, FindAllRequestCacheAction, RequestCacheSuccessAction, - RequestCacheErrorAction, FindByIDRequestCacheAction + RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction, + RequestCacheErrorAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; import { DataService } from "./data.service"; export abstract class DataEffects { - protected abstract getFindAllEndpoint(action: FindAllRequestCacheAction): string; - protected abstract getFindByIdEndpoint(action: FindByIDRequestCacheAction): string; + protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string; + protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string; protected abstract getSerializer(): Serializer; constructor( @@ -24,13 +24,11 @@ export abstract class DataEffects { private dataService: DataService ) {} - // TODO, results of a findall aren't retrieved from cache for now, - // because currently the cache is more of an object store. We need to move - // more towards memoization for things like this. + // TODO, results of a findall aren't retrieved from cache yet protected findAll = this.actions$ - .ofType(RequestCacheActionTypes.FIND_ALL_REQUEST) - .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) - .flatMap((action: FindAllRequestCacheAction) => { + .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)) @@ -40,20 +38,20 @@ export abstract class DataEffects { }); }) .map((ts: Array) => ts.map(t => t.uuid)) - .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, GlobalConfig.cache.msToLive)) + .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), GlobalConfig.cache.msToLive)) .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); protected findById = this.actions$ - .ofType(RequestCacheActionTypes.FIND_BY_ID_REQUEST) - .filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) - .flatMap((action: FindByIDRequestCacheAction) => { + .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) => { this.objectCache.add(t, GlobalConfig.cache.msToLive); }) - .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], GlobalConfig.cache.msToLive)) + .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), GlobalConfig.cache.msToLive)) .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); }); diff --git a/src/app/core/data-services/data.service.ts b/src/app/core/data-services/data.service.ts index 120245c769..07414a0ce7 100644 --- a/src/app/core/data-services/data.service.ts +++ b/src/app/core/data-services/data.service.ts @@ -1,39 +1,39 @@ import { OpaqueToken } from "@angular/core"; import { Observable } from "rxjs"; -import { Store } from "@ngrx/store"; import { ObjectCacheService } from "../cache/object-cache.service"; +import { RequestCacheService } from "../cache/request-cache.service"; import { CacheableObject } from "../cache/object-cache.reducer"; -import { RequestCacheState } from "../cache/request-cache.reducer"; -import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { ParamHash } from "../shared/param-hash"; import { isNotEmpty } from "../../shared/empty.util"; +import { GenericConstructor } from "../shared/generic-constructor"; export abstract class DataService { - abstract name: OpaqueToken; + abstract serviceName: OpaqueToken; + protected abstract objectCache: ObjectCacheService; + protected abstract requestCache: RequestCacheService; - constructor( - private store: Store, - private objectCache: ObjectCacheService - ) { } + constructor(private modelType: GenericConstructor) { + + } findAll(scopeID?: string): Observable> { - const key = new ParamHash(this.name, 'findAll', scopeID).toString(); - this.store.dispatch(new FindAllRequestCacheAction(key, this.name, scopeID)); - //get an observable of the IDs from the store - return this.store.select>('core', 'cache', 'request', key, 'resourceUUIDs') + const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString(); + return this.requestCache.findAll(key, this.serviceName, scopeID) + //get an observable of the IDs from the RequestCache + .map(entry => entry.resourceUUIDs) .flatMap((resourceUUIDs: Array) => { - // use those IDs to fetch the actual objects from the cache - return this.objectCache.getList(resourceUUIDs); + // use those IDs to fetch the actual objects from the ObjectCache + return this.objectCache.getList(resourceUUIDs, this.modelType); }); } findById(id: string): Observable { - const key = new ParamHash(this.name, 'findById', id).toString(); - this.store.dispatch(new FindByIDRequestCacheAction(key, this.name, id)); - return this.store.select>('core', 'cache', 'request', key, 'resourceUUIDs') + const key = new ParamHash(this.serviceName, 'findById', id).toString(); + return this.requestCache.findById(key, this.serviceName, id) + .map(entry => entry.resourceUUIDs) .flatMap((resourceUUIDs: Array) => { if(isNotEmpty(resourceUUIDs)) { - return this.objectCache.get(resourceUUIDs[0]); + return this.objectCache.get(resourceUUIDs[0], this.modelType); } else { return Observable.of(undefined); diff --git a/src/app/core/data-services/item-data.effects.ts b/src/app/core/data-services/item-data.effects.ts index c1b8fb5ef2..d6bce6e8da 100644 --- a/src/app/core/data-services/item-data.effects.ts +++ b/src/app/core/data-services/item-data.effects.ts @@ -6,7 +6,7 @@ import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.seriali import { ObjectCacheService } from "../cache/object-cache.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { Actions, Effect } from "@ngrx/effects"; -import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; +import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; import { ItemDataService } from "./item-data.service"; @Injectable() @@ -20,11 +20,11 @@ export class ItemDataEffects extends DataEffects { super(actions$, restApi, cache, dataService); } - protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { + protected getFindAllEndpoint(action: RequestCacheFindAllAction): string { return '/items'; } - protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { + protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string { return `/items/${action.payload.resourceID}`; } diff --git a/src/app/core/data-services/item-data.service.ts b/src/app/core/data-services/item-data.service.ts index 86b6bf9678..f3c8fd83af 100644 --- a/src/app/core/data-services/item-data.service.ts +++ b/src/app/core/data-services/item-data.service.ts @@ -1,19 +1,18 @@ import { Injectable, OpaqueToken } from "@angular/core"; -import { Store } from "@ngrx/store"; import { DataService } from "./data.service"; import { Item } from "../shared/item.model"; import { ObjectCacheService } from "../cache/object-cache.service"; -import { RequestCacheState } from "../cache/request-cache.reducer"; +import { RequestCacheService } from "../cache/request-cache.service"; @Injectable() export class ItemDataService extends DataService { - name = new OpaqueToken('ItemDataService'); + serviceName = new OpaqueToken('ItemDataService'); constructor( - store: Store, - cache: ObjectCacheService + protected objectCache: ObjectCacheService, + protected requestCache: RequestCacheService, ) { - super(store, cache); + super(Item); } } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts index 3f457daa94..b5fa5983d8 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts @@ -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 = { 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 implements Serializer { * @param modelType a class or interface to indicate * the kind of model this serializer should work with */ - constructor(private modelType: Constructor) { + constructor(private modelType: GenericConstructor) { } /** diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index 3e480bbb09..b990c8617e 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -5,18 +5,19 @@ import { Item } from "./item.model"; @inheritSerialization(DSpaceObject) export class Bundle extends DSpaceObject { - /** - * The primary bitstream of this Bundle - */ - primaryBitstream: Bitstream; + /** + * The primary bitstream of this Bundle + */ + primaryBitstream: Bitstream; - /** - * An array of Items that are direct parents of this Bundle - */ - parents: Array; + /** + * An array of Items that are direct parents of this Bundle + */ + parents: Array; + + /** + * The Item that owns this Bundle + */ + owner: Item; - /** - * The Item that owns this Bundle - */ - owner: Item; } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 4318e6b80e..395886655f 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -8,44 +8,44 @@ import { CacheableObject } from "../cache/object-cache.reducer"; */ export abstract class DSpaceObject implements CacheableObject { - /** - * The human-readable identifier of this DSpaceObject - */ - @autoserialize - id: string; + /** + * The human-readable identifier of this DSpaceObject + */ + @autoserialize + id: string; - /** - * The universally unique identifier of this DSpaceObject - */ - @autoserialize - uuid: string; + /** + * The universally unique identifier of this DSpaceObject + */ + @autoserialize + uuid: string; - /** - * A string representing the kind of DSpaceObject, e.g. community, item, … - */ - type: string; + /** + * A string representing the kind of DSpaceObject, e.g. community, item, … + */ + type: string; - /** - * The name for this DSpaceObject - */ - @autoserialize - name: string; + /** + * The name for this DSpaceObject + */ + @autoserialize + name: string; - /** - * An array containing all metadata of this DSpaceObject - */ - @autoserializeAs(Metadatum) - metadata: Array; + /** + * An array containing all metadata of this DSpaceObject + */ + @autoserializeAs(Metadatum) + metadata: Array; - /** - * An array of DSpaceObjects that are direct parents of this DSpaceObject - */ - parents: Array; + /** + * An array of DSpaceObjects that are direct parents of this DSpaceObject + */ + parents: Array; - /** - * The DSpaceObject that owns this DSpaceObject - */ - owner: DSpaceObject; + /** + * The DSpaceObject that owns this DSpaceObject + */ + owner: DSpaceObject; /** * Find a metadata field by key and language @@ -58,17 +58,17 @@ export abstract class DSpaceObject implements CacheableObject { * @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; - } + 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; + } + } } diff --git a/src/app/core/shared/generic-constructor.ts b/src/app/core/shared/generic-constructor.ts new file mode 100644 index 0000000000..7ee9254fd9 --- /dev/null +++ b/src/app/core/shared/generic-constructor.ts @@ -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 = { new (...args: any[]): T }; + diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index c70d88874d..478d94f814 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,34 +5,35 @@ import { Collection } from "./collection.model"; @inheritSerialization(DSpaceObject) export class Item extends DSpaceObject { - /** - * A string representing the unique handle of this Item - */ - @autoserialize - handle: string; + /** + * A string representing the unique handle of this Item + */ + @autoserialize + handle: string; - /** - * The Date of the last modification of this Item - */ - lastModified: Date; + /** + * 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 archived or not + */ + isArchived: boolean; - /** - * A boolean representing if this Item is currently withdrawn or not - */ - isWithdrawn: 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; + /** + * An array of Collections that are direct parents of this Item + */ + parents: Array; + + /** + * The Collection that owns this Item + */ + owner: Collection; - /** - * The Collection that owns this Item - */ - owner: Collection; } diff --git a/src/app/shared/demo-cache.service.ts b/src/app/shared/demo-cache.service.ts deleted file mode 100644 index 3bdc5ecb59..0000000000 --- a/src/app/shared/demo-cache.service.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Inject, Injectable, isDevMode } from '@angular/core'; - -@Injectable() -export class DemoCacheService { - static KEY = 'DemoCacheService'; - - constructor( @Inject('LRU') public _cache: Map) { - - } - - /** - * 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 DemoCacheService'); - } - - return key + ''; - } - - _isInvalidValue(key): boolean { - return key === null || - key === undefined || - key === 0 || - key === '' || - typeof key === 'boolean' || - Number.isNaN(key); - } -} diff --git a/src/app/shared/model/model.service.ts b/src/app/shared/model/model.service.ts deleted file mode 100644 index 734f931894..0000000000 --- a/src/app/shared/model/model.service.ts +++ /dev/null @@ -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 { DemoCacheService } from '../demo-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: DemoCacheService) { - - } - - /** - * 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 - } -} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 91b9d1b87d..1b7b0ad20e 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -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 ]; diff --git a/src/app/store.actions.ts b/src/app/store.actions.ts new file mode 100644 index 0000000000..3bfb20e31a --- /dev/null +++ b/src/app/store.actions.ts @@ -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; diff --git a/src/browser.module.ts b/src/browser.module.ts index 5ef364fea6..3845f77907 100755 --- a/src/browser.module.ts +++ b/src/browser.module.ts @@ -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,18 @@ import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-tra import { AppModule, AppComponent } from './app/app.module'; import { SharedModule } from './app/shared/shared.module'; -import { DemoCacheService } from './app/shared/demo-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 * as LRU from 'modern-lru'; @@ -38,7 +44,6 @@ export function getResponse() { } -// TODO(gdi2290): refactor into Universal export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; @NgModule({ @@ -60,6 +65,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 +79,21 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; { provide: 'LRU', useFactory: getLRU, deps: [] }, - DemoCacheService, - Meta, // { provide: AUTO_PREBOOT, useValue: false } // turn off auto preboot complete ] }) export class MainModule { - constructor(public cache: DemoCacheService) { + constructor(public store: Store) { // TODO(gdi2290): refactor into a lifecycle hook this.doRehydrate(); } doRehydrate() { let defaultValue = {}; - let serverCache = this._getCacheValue(DemoCacheService.KEY, defaultValue); - this.cache.rehydrate(serverCache); + let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue); + this.store.dispatch(new RehydrateStoreAction(serverCache)); } _getCacheValue(key: string, defaultValue: any): any { @@ -95,7 +102,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; diff --git a/src/node.module.ts b/src/node.module.ts index 6a554e0e8c..8420a76a96 100755 --- a/src/node.module.ts +++ b/src/node.module.ts @@ -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 { DemoCacheService } from './app/shared/demo-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: [] }, - DemoCacheService, - Meta, ] }) export class MainModule { - constructor(public cache: DemoCacheService) { + constructor(public store: Store) { } @@ -76,14 +82,17 @@ export class MainModule { * in Universal for now until it's fixed */ universalDoDehydrate = (universalCache) => { - universalCache[DemoCacheService.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? } } From 07150d3d6a970c1bcf22e4742c092f04d50d313a Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 6 Mar 2017 11:12:05 +0100 Subject: [PATCH 13/21] Removed console.log statement --- src/app/core/cache/request-cache.reducer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts index b867dcbdf8..3378d55d53 100644 --- a/src/app/core/cache/request-cache.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -61,7 +61,6 @@ export const requestCacheReducer = (state = initialState, action: RequestCacheAc }; function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState { - console.log('break here', state); return Object.assign({}, state, { [action.payload.key]: { key: action.payload.key, From f1bf3e4062611fb7677be9f428c1e6c98120af82 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 6 Mar 2017 14:42:19 +0100 Subject: [PATCH 14/21] ObjectCache reducer tests & docs --- karma.conf.js | 4 + .../core/cache/object-cache.reducer.spec.ts | 101 ++++++++++++++++++ src/app/core/cache/object-cache.reducer.ts | 44 ++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/app/core/cache/object-cache.reducer.spec.ts diff --git a/karma.conf.js b/karma.conf.js index 43ad307a3c..07be7852f0 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -132,6 +132,10 @@ module.exports = function(config) { } }, + mochaReporter: { + ignoreSkipped: true + } + /* * Continuous Integration mode * if true, Karma captures browsers, runs the tests and exits diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts new file mode 100644 index 0000000000..dbc0c20eed --- /dev/null +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -0,0 +1,101 @@ +import * as deepFreeze from "deep-freeze"; +import { objectCacheReducer } from "./object-cache.reducer"; +import { + AddToObjectCacheAction, + RemoveFromObjectCacheAction +} from "./object-cache.actions"; + +class NullAction extends RemoveFromObjectCacheAction { + type = null; + payload = null; + + constructor() { + super(null); + } +} + +describe("objectCacheReducer", () => { + const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const testState = { + [uuid]: { + data: { + uuid: uuid, + foo: "bar" + }, + 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: uuid}; + const timeAdded = new Date().getTime(); + const msToLive = 900000; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive); + const newState = objectCacheReducer(state, action); + + expect(newState[uuid].data).toEqual(objectToCache); + expect(newState[uuid].timeAdded).toEqual(timeAdded); + expect(newState[uuid].msToLive).toEqual(msToLive); + }); + + it("should overwrite an object in the cache in response to an ADD action if it already exists", () => { + const objectToCache = {uuid: uuid, 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[uuid].data['foo']).toBe("baz"); + expect(newState[uuid].data['somethingElse']).toBe(true); + }); + + it("should perform the ADD action without affecting the previous state", () => { + const state = Object.create(null); + const objectToCache = {uuid: uuid}; + 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(uuid); + const newState = objectCacheReducer(testState, action); + + expect(testState[uuid]).not.toBeUndefined(); + expect(newState[uuid]).toBeUndefined(); + }); + + it("shouldn't do anything in response to the REMOVE action for an object that isn't cached", () => { + const action = new RemoveFromObjectCacheAction("this isn't cached"); + const newState = objectCacheReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it("should perform the REMOVE action without affecting the previous state", () => { + const action = new RemoveFromObjectCacheAction(uuid); + //testState has already been frozen above + objectCacheReducer(testState, action); + }); + +}); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 93e9f6ff05..5e0eacdc74 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -2,16 +2,30 @@ import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, Remo 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 } @@ -19,6 +33,16 @@ export interface ObjectCacheState { // 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) { @@ -36,6 +60,16 @@ export const objectCacheReducer = (state = initialState, action: ObjectCacheActi } }; +/** + * 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]: { @@ -46,6 +80,16 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio }); } +/** + * 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); From 0e516beabddb461bedfc7dc62c9e9fa2b8a831b7 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 8 Mar 2017 12:57:03 +0100 Subject: [PATCH 15/21] ObjectCacheService docs & tests --- .../core/cache/object-cache.service.spec.ts | 112 ++++++++++++++++++ src/app/core/cache/object-cache.service.ts | 77 +++++++++++- src/app/core/data-services/data.effects.ts | 11 +- 3 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/app/core/cache/object-cache.service.spec.ts diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts new file mode 100644 index 0000000000..a05d6fdbef --- /dev/null +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -0,0 +1,112 @@ +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; + + 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(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 testObj: any; + service.get(uuid, TestClass).take(1).subscribe(o => testObj = o); + expect(testObj).toBeUndefined(); + }); + }); + + 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; + 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); + }); + }); +}); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index c9d3bccf83..6cabbfd91d 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -6,33 +6,93 @@ 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 ) {} + /** + * 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(uuid: string, ctor: GenericConstructor): Observable { + /** + * 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 + */ + get(uuid: string, type: GenericConstructor): Observable { return this.store.select('core', 'cache', 'object', uuid) .filter(entry => this.isValid(entry)) .distinctUntilChanged() - .map((entry: ObjectCacheEntry) => Object.assign(new ctor(), entry.data)); + .map((entry: ObjectCacheEntry) => Object.assign(new type(), entry.data)); } - getList(uuids: Array, ctor: GenericConstructor): Observable> { + /** + * 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> + */ + getList(uuids: Array, type: GenericConstructor): Observable> { return Observable.combineLatest( - uuids.map((id: string) => this.get(id, ctor)) + uuids.map((id: string) => this.get(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; @@ -43,6 +103,15 @@ export class ObjectCacheService { 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; diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts index c39a718732..a68f7f2899 100644 --- a/src/app/core/data-services/data.effects.ts +++ b/src/app/core/data-services/data.effects.ts @@ -11,6 +11,7 @@ import { RequestCacheErrorAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; import { DataService } from "./data.service"; +import { hasNoValue } from "../../shared/empty.util"; export abstract class DataEffects { protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string; @@ -34,12 +35,15 @@ export abstract class DataEffects { .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) => ts.map(t => t.uuid)) .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), GlobalConfig.cache.msToLive)) - .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); + .catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message))); }); protected findById = this.actions$ @@ -49,10 +53,13 @@ export abstract class DataEffects { 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((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); + .catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message))); }); } From a535b86e0b8b2e615933ed7442b6b2dbcc2b1064 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 8 Mar 2017 16:36:34 +0100 Subject: [PATCH 16/21] fix timestamps on rehydrate --- src/app/core/cache/object-cache.actions.ts | 13 ++++- .../core/cache/object-cache.reducer.spec.ts | 56 +++++++++++++------ src/app/core/cache/object-cache.reducer.ts | 29 +++++++++- src/app/core/cache/request-cache.actions.ts | 15 ++++- src/app/core/cache/request-cache.reducer.ts | 17 +++++- src/app/core/core.effects.ts | 6 +- .../data-services/object-cache.effects.ts | 28 ++++++++++ .../data-services/request-cache.effects.ts | 36 ++++++++++++ 8 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 src/app/core/data-services/object-cache.effects.ts create mode 100644 src/app/core/data-services/request-cache.effects.ts diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 21855e5170..93cf247024 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -4,7 +4,8 @@ import { CacheableObject } from "./object-cache.reducer"; export const ObjectCacheActionTypes = { ADD: type('dspace/core/cache/object/ADD'), - REMOVE: type('dspace/core/cache/object/REMOVE') + REMOVE: type('dspace/core/cache/object/REMOVE'), + RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS') }; export class AddToObjectCacheAction implements Action { @@ -29,6 +30,16 @@ export class RemoveFromObjectCacheAction implements Action { } } +export class ResetObjectCacheTimestampsAction implements Action { + type = ObjectCacheActionTypes.RESET_TIMESTAMPS; + payload: number; + + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + export type ObjectCacheAction = AddToObjectCacheAction | RemoveFromObjectCacheAction + | ResetObjectCacheTimestampsAction; diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index dbc0c20eed..eb9422d93a 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,7 +2,7 @@ import * as deepFreeze from "deep-freeze"; import { objectCacheReducer } from "./object-cache.reducer"; import { AddToObjectCacheAction, - RemoveFromObjectCacheAction + RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction } from "./object-cache.actions"; class NullAction extends RemoveFromObjectCacheAction { @@ -15,15 +15,24 @@ class NullAction extends RemoveFromObjectCacheAction { } describe("objectCacheReducer", () => { - const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const uuid1 = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const uuid2 = '28b04544-1766-4e82-9728-c4e93544ecd3'; const testState = { - [uuid]: { + [uuid1]: { data: { - uuid: uuid, + uuid: uuid1, foo: "bar" }, timeAdded: new Date().getTime(), msToLive: 900000 + }, + [uuid2]: { + data: { + uuid: uuid2, + foo: "baz" + }, + timeAdded: new Date().getTime(), + msToLive: 900000 } }; deepFreeze(testState); @@ -44,31 +53,31 @@ describe("objectCacheReducer", () => { it("should add the payload to the cache in response to an ADD action", () => { const state = Object.create(null); - const objectToCache = {uuid: uuid}; + 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[uuid].data).toEqual(objectToCache); - expect(newState[uuid].timeAdded).toEqual(timeAdded); - expect(newState[uuid].msToLive).toEqual(msToLive); + 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: uuid, foo: "baz", somethingElse: true}; + 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[uuid].data['foo']).toBe("baz"); - expect(newState[uuid].data['somethingElse']).toBe(true); + 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: uuid}; + const objectToCache = {uuid: uuid1}; const timeAdded = new Date().getTime(); const msToLive = 900000; const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive); @@ -78,11 +87,11 @@ describe("objectCacheReducer", () => { }); it("should remove the specified object from the cache in response to the REMOVE action", () => { - const action = new RemoveFromObjectCacheAction(uuid); + const action = new RemoveFromObjectCacheAction(uuid1); const newState = objectCacheReducer(testState, action); - expect(testState[uuid]).not.toBeUndefined(); - expect(newState[uuid]).toBeUndefined(); + 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", () => { @@ -93,7 +102,22 @@ describe("objectCacheReducer", () => { }); it("should perform the REMOVE action without affecting the previous state", () => { - const action = new RemoveFromObjectCacheAction(uuid); + 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); }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 5e0eacdc74..23b0188216 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,4 +1,7 @@ -import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; +import { + ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, + RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction +} from "./object-cache.actions"; import { hasValue } from "../../shared/empty.util"; import { CacheEntry } from "./cache-entry"; @@ -54,6 +57,10 @@ export const objectCacheReducer = (state = initialState, action: ObjectCacheActi return removeFromObjectCache(state, action) } + case ObjectCacheActionTypes.RESET_TIMESTAMPS: { + return resetObjectCacheTimestamps(state, action) + } + default: { return state; } @@ -101,3 +108,23 @@ function removeFromObjectCache(state: ObjectCacheState, action: RemoveFromObject 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; +} diff --git a/src/app/core/cache/request-cache.actions.ts b/src/app/core/cache/request-cache.actions.ts index 85d7ca7fa3..a5d7e73ef4 100644 --- a/src/app/core/cache/request-cache.actions.ts +++ b/src/app/core/cache/request-cache.actions.ts @@ -9,7 +9,8 @@ export const RequestCacheActionTypes = { 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') + REMOVE: type('dspace/core/cache/request/REMOVE'), + RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS') }; export class RequestCacheFindAllAction implements Action { @@ -103,9 +104,19 @@ export class RequestCacheRemoveAction implements Action { } } +export class ResetRequestCacheTimestampsAction implements Action { + type = RequestCacheActionTypes.RESET_TIMESTAMPS; + payload: number; + + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + export type RequestCacheAction = RequestCacheFindAllAction | RequestCacheFindByIDAction | RequestCacheSuccessAction | RequestCacheErrorAction - | RequestCacheRemoveAction; + | RequestCacheRemoveAction + | ResetRequestCacheTimestampsAction; diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts index 3378d55d53..340b023548 100644 --- a/src/app/core/cache/request-cache.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -3,7 +3,7 @@ import { SortOptions } from "../shared/sort-options.model"; import { RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction, - RequestCacheRemoveAction + RequestCacheRemoveAction, ResetRequestCacheTimestampsAction } from "./request-cache.actions"; import { OpaqueToken } from "@angular/core"; import { CacheEntry } from "./cache-entry"; @@ -54,6 +54,10 @@ export const requestCacheReducer = (state = initialState, action: RequestCacheAc return removeFromCache(state, action); } + case RequestCacheActionTypes.RESET_TIMESTAMPS: { + return resetRequestCacheTimestamps(state, action) + } + default: { return state; } @@ -121,5 +125,12 @@ function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAct } } - - +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; +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index cc9835dd2d..b2d6c95ad5 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,8 +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(ItemDataEffects), + EffectsModule.run(RequestCacheEffects), + EffectsModule.run(ObjectCacheEffects), ]; diff --git a/src/app/core/data-services/object-cache.effects.ts b/src/app/core/data-services/object-cache.effects.ts new file mode 100644 index 0000000000..26f13ea1b5 --- /dev/null +++ b/src/app/core/data-services/object-cache.effects.ts @@ -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 + ) { } + + /** + * 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())); + +} diff --git a/src/app/core/data-services/request-cache.effects.ts b/src/app/core/data-services/request-cache.effects.ts new file mode 100644 index 0000000000..b8dde51159 --- /dev/null +++ b/src/app/core/data-services/request-cache.effects.ts @@ -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 + ) { } + + /** + * 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())); + +} From eaaf12d6456f915ad2e520604044ec1080ff4b7a Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 8 Mar 2017 18:30:55 +0100 Subject: [PATCH 17/21] requestCacheReducer docs & tests --- src/app/core/cache/object-cache.actions.ts | 37 +++ .../core/cache/object-cache.reducer.spec.ts | 4 +- src/app/core/cache/request-cache.actions.ts | 83 +++++++ .../core/cache/request-cache.reducer.spec.ts | 227 ++++++++++++++++++ src/app/core/cache/request-cache.reducer.ts | 76 ++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 src/app/core/cache/request-cache.reducer.spec.ts diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 93cf247024..cc9e557de4 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -2,12 +2,18 @@ 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: { @@ -16,29 +22,60 @@ export class AddToObjectCacheAction implements Action { 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 diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index eb9422d93a..4d8e116e4f 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -95,9 +95,11 @@ describe("objectCacheReducer", () => { }); it("shouldn't do anything in response to the REMOVE action for an object that isn't cached", () => { - const action = new RemoveFromObjectCacheAction("this 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); }); diff --git a/src/app/core/cache/request-cache.actions.ts b/src/app/core/cache/request-cache.actions.ts index a5d7e73ef4..78c6692d71 100644 --- a/src/app/core/cache/request-cache.actions.ts +++ b/src/app/core/cache/request-cache.actions.ts @@ -4,6 +4,9 @@ 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'), @@ -13,6 +16,9 @@ export const RequestCacheActionTypes = { 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: { @@ -23,6 +29,20 @@ export class RequestCacheFindAllAction implements Action { 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, @@ -40,6 +60,9 @@ export class RequestCacheFindAllAction implements Action { } } +/** + * An ngrx action to find objects by id + */ export class RequestCacheFindByIDAction implements Action { type = RequestCacheActionTypes.FIND_BY_ID; payload: { @@ -48,6 +71,16 @@ export class RequestCacheFindByIDAction implements Action { 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, @@ -61,6 +94,9 @@ export class RequestCacheFindByIDAction implements Action { } } +/** + * An ngrx action to indicate a request was returned successful + */ export class RequestCacheSuccessAction implements Action { type = RequestCacheActionTypes.SUCCESS; payload: { @@ -70,6 +106,20 @@ export class RequestCacheSuccessAction implements Action { 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, timeAdded, msToLive: number) { this.payload = { key, @@ -80,6 +130,9 @@ export class RequestCacheSuccessAction implements Action { } } +/** + * An ngrx action to indicate a request failed + */ export class RequestCacheErrorAction implements Action { type = RequestCacheActionTypes.ERROR; payload: { @@ -87,6 +140,16 @@ export class RequestCacheErrorAction implements Action { 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, @@ -95,24 +158,44 @@ export class RequestCacheErrorAction implements Action { } } +/** + * 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 diff --git a/src/app/core/cache/request-cache.reducer.spec.ts b/src/app/core/cache/request-cache.reducer.spec.ts new file mode 100644 index 0000000000..56bea6a83e --- /dev/null +++ b/src/app/core/cache/request-cache.reducer.spec.ts @@ -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); + }); + }); + + }); + + +}); diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts index 340b023548..0aa2e8c920 100644 --- a/src/app/core/cache/request-cache.reducer.ts +++ b/src/app/core/cache/request-cache.reducer.ts @@ -9,6 +9,9 @@ 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; @@ -24,6 +27,9 @@ export class RequestCacheEntry implements CacheEntry { msToLive: number; } +/** + * The RequestCache State + */ export interface RequestCacheState { [key: string]: RequestCacheEntry } @@ -31,6 +37,16 @@ export interface RequestCacheState { // 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) { @@ -64,6 +80,16 @@ export const requestCacheReducer = (state = initialState, action: RequestCacheAc } }; +/** + * 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]: { @@ -79,6 +105,16 @@ function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAct }); } +/** + * 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]: { @@ -92,6 +128,16 @@ function findByIDRequest(state: RequestCacheState, action: RequestCacheFindByIDA }); } +/** + * 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], { @@ -104,6 +150,16 @@ function success(state: RequestCacheState, action: RequestCacheSuccessAction): R }); } +/** + * 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], { @@ -113,6 +169,16 @@ function error(state: RequestCacheState, action: RequestCacheErrorAction): Reque }); } +/** + * 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); @@ -125,6 +191,16 @@ function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAct } } +/** + * 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 => { From 34d9aece7e27631effd28d9f590a0c09c48da0e1 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 14 Mar 2017 14:36:03 +0100 Subject: [PATCH 18/21] RequestCacheService docs & tests --- .../core/cache/object-cache.service.spec.ts | 7 +- src/app/core/cache/object-cache.service.ts | 1 + .../core/cache/request-cache.service.spec.ts | 147 ++++++++++++++++++ src/app/core/cache/request-cache.service.ts | 65 ++++++++ 4 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/app/core/cache/request-cache.service.spec.ts diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index a05d6fdbef..827e39ab7e 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -72,9 +72,10 @@ describe("ObjectCacheService", () => { it("should not return a cached object that has exceeded its time to live", () => { spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry)); - let testObj: any; - service.get(uuid, TestClass).take(1).subscribe(o => testObj = o); - expect(testObj).toBeUndefined(); + let getObsHasFired = false; + const subscription = service.get(uuid, TestClass).subscribe(o => getObsHasFired = true); + expect(getObsHasFired).toBe(false); + subscription.unsubscribe(); }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 6cabbfd91d..9093093f50 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -51,6 +51,7 @@ export class ObjectCacheService { * @param type * The type of the object to get * @return Observable + * An observable of the requested object */ get(uuid: string, type: GenericConstructor): Observable { return this.store.select('core', 'cache', 'object', uuid) diff --git a/src/app/core/cache/request-cache.service.spec.ts b/src/app/core/cache/request-cache.service.spec.ts new file mode 100644 index 0000000000..eb4a07a742 --- /dev/null +++ b/src/app/core/cache/request-cache.service.spec.ts @@ -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; + + 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(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) => { + 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) => { + 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); + }); + }); +}); diff --git a/src/app/core/cache/request-cache.service.ts b/src/app/core/cache/request-cache.service.ts index 418a1ad204..efa7b0d426 100644 --- a/src/app/core/cache/request-cache.service.ts +++ b/src/app/core/cache/request-cache.service.ts @@ -10,12 +10,35 @@ import { 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 ) {} + /** + * 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 + * an observable of the RequestCacheEntry for this request + */ findAll( key: string, service: OpaqueToken, @@ -29,6 +52,22 @@ export class RequestCacheService { 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 + * an observable of the RequestCacheEntry for this request + */ findById( key: string, service: OpaqueToken, @@ -40,12 +79,29 @@ export class RequestCacheService { 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 + * an observable of the RequestCacheEntry with the specified key + */ get(key: string): Observable { return this.store.select('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; @@ -56,6 +112,15 @@ export class RequestCacheService { 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; From 57c20d61839c4f0419ff33fcf6c4b90b03228686 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 15 Mar 2017 11:39:46 +0100 Subject: [PATCH 19/21] updated yarn.lock --- yarn.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/yarn.lock b/yarn.lock index be311d1cd2..0b1345e294 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From a4579e54357da3037eda18543cd40aaaa976fcfb Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 15 Mar 2017 18:24:23 +0100 Subject: [PATCH 20/21] Added a configuration parameter to enable/disable the rehydrate step --- config/environment.default.js | 6 +++++- src/browser.module.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/config/environment.default.js b/config/environment.default.js index a39db0dff8..47c4213436 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -13,6 +13,10 @@ module.exports = { }, "cache": { // how long should objects be cached for by default - "msToLive": 15 * 60 * 1000 //15 minutes + "msToLive": 15 * 60 * 1000, //15 minutes + }, + "universal": { + //on the client: start with the state on the server + "shouldRehydrate": true } }; diff --git a/src/browser.module.ts b/src/browser.module.ts index 3845f77907..2c77fc2e2e 100755 --- a/src/browser.module.ts +++ b/src/browser.module.ts @@ -22,6 +22,7 @@ import { effects } from './app/app.effects'; // 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'; @@ -91,9 +92,11 @@ export class MainModule { } doRehydrate() { - let defaultValue = {}; - let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue); - this.store.dispatch(new RehydrateStoreAction(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 { From a62c8d62c0ba107b2b341b8f89f9dd96594f713e Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 16 Mar 2017 14:44:54 +0100 Subject: [PATCH 21/21] introduced remotedata selector --- src/app/core/data-services/data.service.ts | 48 ++++++++------ src/app/core/data-services/remote-data.ts | 75 ++++++++++++++++++++++ 2 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 src/app/core/data-services/remote-data.ts diff --git a/src/app/core/data-services/data.service.ts b/src/app/core/data-services/data.service.ts index 07414a0ce7..ddbfa03eb4 100644 --- a/src/app/core/data-services/data.service.ts +++ b/src/app/core/data-services/data.service.ts @@ -6,6 +6,7 @@ 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 { abstract serviceName: OpaqueToken; @@ -16,29 +17,38 @@ export abstract class DataService { } - findAll(scopeID?: string): Observable> { + findAll(scopeID?: string): RemoteData> { const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString(); - return this.requestCache.findAll(key, this.serviceName, scopeID) - //get an observable of the IDs from the RequestCache - .map(entry => entry.resourceUUIDs) - .flatMap((resourceUUIDs: Array) => { - // use those IDs to fetch the actual objects from the ObjectCache - return this.objectCache.getList(resourceUUIDs, this.modelType); - }); + 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) => { + // use those IDs to fetch the actual objects from the ObjectCache + return this.objectCache.getList(resourceUUIDs, this.modelType); + }).distinctUntilChanged() + ); } - findById(id: string): Observable { + findById(id: string): RemoteData { const key = new ParamHash(this.serviceName, 'findById', id).toString(); - return this.requestCache.findById(key, this.serviceName, id) - .map(entry => entry.resourceUUIDs) - .flatMap((resourceUUIDs: Array) => { - if(isNotEmpty(resourceUUIDs)) { - return this.objectCache.get(resourceUUIDs[0], this.modelType); - } - else { - return Observable.of(undefined); - } - }); + 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) => { + if (isNotEmpty(resourceUUIDs)) { + return this.objectCache.get(resourceUUIDs[0], this.modelType); + } + else { + return Observable.of(undefined); + } + }).distinctUntilChanged() + ); } } diff --git a/src/app/core/data-services/remote-data.ts b/src/app/core/data-services/remote-data.ts new file mode 100644 index 0000000000..1b9ff177ef --- /dev/null +++ b/src/app/core/data-services/remote-data.ts @@ -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 { + + constructor( + private storeLoading: Observable, + public errorMessage: Observable, + public payload: Observable + ) { + } + + get state(): Observable { + 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 { + return this.state + .map(state => state == RemoteDataState.RequestPending) + .distinctUntilChanged(); + } + + get isResponsePending(): Observable { + return this.state + .map(state => state == RemoteDataState.ResponsePending) + .distinctUntilChanged(); + } + + get isLoading(): Observable { + return this.state + .map(state => { + return state == RemoteDataState.RequestPending + || state === RemoteDataState.ResponsePending + }) + .distinctUntilChanged(); + } + + get hasFailed(): Observable { + return this.state + .map(state => state == RemoteDataState.Failed) + .distinctUntilChanged(); + } + + get hasSucceeded(): Observable { + return this.state + .map(state => state == RemoteDataState.Success) + .distinctUntilChanged(); + } + +}