diff --git a/projects/ngx-hal/src/lib/classes/hal-storage/cache-first-fetch-later.storage.ts b/projects/ngx-hal/src/lib/classes/hal-storage/cache-first-fetch-later.storage.ts new file mode 100644 index 0000000..1977ad3 --- /dev/null +++ b/projects/ngx-hal/src/lib/classes/hal-storage/cache-first-fetch-later.storage.ts @@ -0,0 +1,28 @@ +import { from, Observable, of } from 'rxjs'; +import { distinctUntilChanged, mergeMap } from 'rxjs/operators'; +import { HalModel } from '../../models/hal.model'; +import { HalDocument } from '../hal-document'; +import { EtagHalStorage } from './etag-hal-storage'; + +export class CacheFirstFetchLaterStorage extends EtagHalStorage { + public makeGetRequestWrapper( + urls: { originalUrl: string; cleanUrl: string; urlWithParams: string }, + cachedResource: T | HalDocument, + originalGetRequest$: Observable> + ): Observable> { + if (cachedResource) { + return from([of(cachedResource), originalGetRequest$]).pipe( + mergeMap((request) => request), + distinctUntilChanged(this.areModelsEqual.bind(this)) + ); + } + + return originalGetRequest$; + } + + private areModelsEqual(model1: T | HalDocument, model2: T | HalDocument): boolean { + const localModel1 = this.getRawStorageModel(model1.uniqueModelIdentificator); + const localModel2 = this.getRawStorageModel(model2.uniqueModelIdentificator); + return localModel1.etag === localModel2.etag; + } +} diff --git a/projects/ngx-hal/src/lib/classes/hal-storage/etag-hal-storage.ts b/projects/ngx-hal/src/lib/classes/hal-storage/etag-hal-storage.ts index 2e83f15..3c8c48f 100644 --- a/projects/ngx-hal/src/lib/classes/hal-storage/etag-hal-storage.ts +++ b/projects/ngx-hal/src/lib/classes/hal-storage/etag-hal-storage.ts @@ -44,7 +44,7 @@ export class EtagHalStorage extends HalStorage { } } - private getRawStorageModel(uniqueModelIdentificator: string): StorageModel { + protected getRawStorageModel(uniqueModelIdentificator: string): StorageModel { return this.internalStorage[uniqueModelIdentificator]; } diff --git a/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage-factory.ts b/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage-factory.ts index 85f5ef8..42d7163 100644 --- a/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage-factory.ts +++ b/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage-factory.ts @@ -1,10 +1,12 @@ import { CacheStrategy } from '../../enums/cache-strategy.enum'; import { SimpleHalStorage } from '../../classes/hal-storage/simple-hal-storage'; import { EtagHalStorage } from '../../classes/hal-storage/etag-hal-storage'; +import { HalStorage } from './hal-storage'; +import { CacheFirstFetchLaterStorage } from './cache-first-fetch-later.storage'; export type HalStorageType = SimpleHalStorage | EtagHalStorage; -export function createHalStorage(cacheStrategy: CacheStrategy = CacheStrategy.NONE): HalStorageType { +export function createHalStorage(cacheStrategy: CacheStrategy = CacheStrategy.NONE, storageInstance: HalStorage): HalStorageType { let storage: HalStorageType; switch (cacheStrategy) { @@ -14,6 +16,15 @@ export function createHalStorage(cacheStrategy: CacheStrategy = CacheStrategy.NO case CacheStrategy.ETAG: storage = new EtagHalStorage(); break; + case CacheStrategy.CACHE_FIRST_FETCH_LATER: + storage = new CacheFirstFetchLaterStorage(); + break; + case CacheStrategy.CUSTOM: + if (!storageInstance) { + throw new Error('When CacheStrategy.CUSTOM is specified, config.storage is required.'); + } + storage = storageInstance; + break; default: throw new Error(`Unknown CacheStrategy: ${cacheStrategy}`); break; diff --git a/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage.ts b/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage.ts index d604cfc..c69f9d1 100644 --- a/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage.ts +++ b/projects/ngx-hal/src/lib/classes/hal-storage/hal-storage.ts @@ -2,6 +2,8 @@ import { HalModel } from '../../models/hal.model'; import { HalDocument } from './../hal-document'; import { HttpResponse } from '@angular/common/http'; import { RequestOptions } from '../../types/request-options.type'; +import { Observable } from 'rxjs'; +import { ModelConstructor } from '../../types/model-constructor.type'; export abstract class HalStorage { protected internalStorage: { [K: string]: any } = {}; @@ -29,4 +31,14 @@ export abstract class HalStorage { public enrichRequestOptions(uniqueModelIdentificator: string, requestOptions: RequestOptions): void { // noop } + + public makeGetRequestWrapper?( + urls: { originalUrl: string; cleanUrl: string; urlWithParams: string }, + cachedResource: T | HalDocument, + originalGetRequest$: Observable>, + requestOptions: RequestOptions, + modelClass: ModelConstructor, + isSingleResource: boolean, + storePartialModels?: boolean + ): Observable>; } diff --git a/projects/ngx-hal/src/lib/decorators/datastore-config.decorator.ts b/projects/ngx-hal/src/lib/decorators/datastore-config.decorator.ts index 906eaf6..8dc13ae 100644 --- a/projects/ngx-hal/src/lib/decorators/datastore-config.decorator.ts +++ b/projects/ngx-hal/src/lib/decorators/datastore-config.decorator.ts @@ -8,6 +8,7 @@ export function DatastoreConfig(config: DatastoreOptions) { const networkConfig = deepmergeWrapper(DEFAULT_NETWORK_CONFIG, config.network || {}); Object.defineProperty(target.prototype, 'paginationClass', { value: config.paginationClass }); Object.defineProperty(target.prototype, 'cacheStrategy', { value: config.cacheStrategy }); + Object.defineProperty(target.prototype, '_storage', { value: config.storage }); Object.defineProperty(target.prototype, 'networkConfig', { value: networkConfig, writable: true }); Reflect.defineMetadata(HAL_DATASTORE_DOCUMENT_CLASS_METADATA_KEY, config.halDocumentClass, target); return target; diff --git a/projects/ngx-hal/src/lib/enums/cache-strategy.enum.ts b/projects/ngx-hal/src/lib/enums/cache-strategy.enum.ts index 89c0865..0161a08 100644 --- a/projects/ngx-hal/src/lib/enums/cache-strategy.enum.ts +++ b/projects/ngx-hal/src/lib/enums/cache-strategy.enum.ts @@ -1,4 +1,6 @@ export enum CacheStrategy { + CACHE_FIRST_FETCH_LATER = 'CACHE_FIRST_FETCH_LATER', + CUSTOM = 'CUSTOM', ETAG = 'ETAG', NONE = 'NONE' } diff --git a/projects/ngx-hal/src/lib/interfaces/datastore-options.interface.ts b/projects/ngx-hal/src/lib/interfaces/datastore-options.interface.ts index 79e4e9b..7ff2f46 100644 --- a/projects/ngx-hal/src/lib/interfaces/datastore-options.interface.ts +++ b/projects/ngx-hal/src/lib/interfaces/datastore-options.interface.ts @@ -3,10 +3,12 @@ import { HalModel } from '../models/hal.model'; import { HalDocumentConstructor } from '../types/hal-document-construtor.type'; import { PaginationConstructor } from '../types/pagination.type'; import { CacheStrategy } from '../enums/cache-strategy.enum'; +import { HalStorage } from '../classes/hal-storage/hal-storage'; export interface DatastoreOptions { network?: NetworkConfig; halDocumentClass?: HalDocumentConstructor; paginationClass?: PaginationConstructor; cacheStrategy?: CacheStrategy; + storage?: HalStorage; } diff --git a/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts b/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts index 3c4c598..b0c9d2e 100644 --- a/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts +++ b/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpResponse, HttpParams } from '@angular/common/http'; -import { Observable, combineLatest, of, throwError } from 'rxjs'; -import { map, flatMap, tap, catchError } from 'rxjs/operators'; +import { Observable, combineLatest, of, throwError, from } from 'rxjs'; +import { map, flatMap, tap, catchError, mergeMap, delay } from 'rxjs/operators'; import * as UriTemplate from 'uri-templates'; import { NetworkConfig, DEFAULT_NETWORK_CONFIG } from '../../interfaces/network-config.interface'; import { HalModel } from '../../models/hal.model'; @@ -32,12 +32,15 @@ import { RelationshipRequestDescriptor } from '../../types/relationship-request- import { ensureRelationshipRequestDescriptors } from '../../utils/ensure-relationship-descriptors/ensure-relationship-descriptors.util'; import { RelationshipDescriptorMappings } from '../../types/relationship-descriptor-mappings.type'; import { EMBEDDED_PROPERTY_NAME } from '../../constants/hal.constant'; +import { HalStorage } from '../../classes/hal-storage/hal-storage'; @Injectable() export class DatastoreService { public networkConfig: NetworkConfig = this['networkConfig'] || DEFAULT_NETWORK_CONFIG; - private cacheStrategy: CacheStrategy; - private internalStorage = createHalStorage(this.cacheStrategy); + private cacheStrategy: CacheStrategy; // set by Config decorator + // tslint:disable-next-line + private _storage: HalStorage; // set by Config decorator + private internalStorage = createHalStorage(this.cacheStrategy, this._storage); protected httpParamsOptions?: object; public paginationClass: PaginationConstructor; @@ -272,7 +275,7 @@ export class DatastoreService { return of(fetchedModels); } - const httpRequest$ = this.makeGetRequest(url, requestsOptions.mainRequest, modelClass, isSingleResource, storePartialModels); + const httpRequest$ = this.makeGetRequestWrapper(url, requestsOptions, modelClass, isSingleResource, storePartialModels); if (includeRelationships.length) { return httpRequest$.pipe( @@ -299,6 +302,37 @@ export class DatastoreService { return httpRequest$; } + private makeGetRequestWrapper( + url: string, + requestsOptions: RequestsOptions, + modelClass: ModelConstructor, + isSingleResource: boolean, + storePartialModels?: boolean + ): Observable | T> { + const originalGetRequest$: Observable> = this.makeGetRequest( + url, + requestsOptions.mainRequest, + modelClass, + isSingleResource, + storePartialModels + ); + + if (this.storage.makeGetRequestWrapper) { + const { cleanUrl, urlWithParams, requestOptions: options } = this.extractRequestInfo(url, requestsOptions.mainRequest); + const cachedResoucesFromUrl = this.storage.get(decodeURIComponent(url)) || this.storage.get(decodeURIComponent(urlWithParams)); + return this.storage.makeGetRequestWrapper( + { cleanUrl, urlWithParams, originalUrl: url }, + cachedResoucesFromUrl, + originalGetRequest$, + options, + modelClass, + storePartialModels + ); + } + + return originalGetRequest$; + } + private triggerFetchingModelRelationships( models: Array, includeRelationships: Array, @@ -668,32 +702,16 @@ export class DatastoreService { singleResource: boolean, storePartialModels?: boolean ): Observable | T> { - const params: object = this.ensureParamsObject(requestOptions.params || {}); - Object.assign(requestOptions, { params }); - - const options: any = deepmergeWrapper(DEFAULT_REQUEST_OPTIONS, this.networkConfig.globalRequestOptions, requestOptions); - - this.storage.enrichRequestOptions(url, options); - - const templatedUrl: string = (new UriTemplate(url)).fill(options.params); + const { cleanUrl, requestOptions: options, urlWithParams } = this.extractRequestInfo(url, requestOptions); - const urlQueryParams: object = getQueryParams(templatedUrl); - options.params = Object.assign(urlQueryParams, options.params); - - const cleanUrl: string = removeQueryParams(templatedUrl); - const queryParamsString: string = makeQueryParamsString(options.params, true); - const urlWithParams = queryParamsString ? `${cleanUrl}?${queryParamsString}` : cleanUrl; - - options.params = makeHttpParams(options.params, this.httpParamsOptions); - - return this.http.get(cleanUrl, options).pipe( + return this.http.get(cleanUrl, options as any).pipe( map((response: HttpResponse) => { const rawResource: RawHalResource = this.extractResourceFromResponse(response); return this.processRawResource(rawResource, modelClass, singleResource, response, urlWithParams, storePartialModels); }), catchError((response: HttpResponse) => { if (response.status === 304) { - const cachedModel: T = this.storage.get(url); + const cachedModel: T = this.storage.get(url) || this.storage.get(response.url); if (cachedModel) { return of(cachedModel); @@ -705,6 +723,34 @@ export class DatastoreService { ); } + private extractRequestInfo( + url: string, + options: RequestOptions + ): { cleanUrl: string, urlWithParams: string, requestOptions: RequestOptions } { + const params: object = this.ensureParamsObject(options.params || {}); + Object.assign(options, { params }); + const requestOptions: RequestOptions = deepmergeWrapper(DEFAULT_REQUEST_OPTIONS, this.networkConfig.globalRequestOptions, options); + + this.storage.enrichRequestOptions(url, options); + + const templatedUrl: string = (new UriTemplate(url)).fill(options.params); + + const urlQueryParams: object = getQueryParams(templatedUrl); + requestOptions.params = Object.assign(urlQueryParams, requestOptions.params); + + const cleanUrl: string = removeQueryParams(templatedUrl); + const queryParamsString: string = makeQueryParamsString(requestOptions.params, true); + const urlWithParams = queryParamsString ? `${cleanUrl}?${queryParamsString}` : cleanUrl; + + requestOptions.params = makeHttpParams(requestOptions.params, this.httpParamsOptions); + + return { + cleanUrl, + urlWithParams, + requestOptions + }; + } + private ensureParamsObject( params: HttpParams | { [param: string]: string | string[] } | object ): { [param: string]: string | string[]} | object { diff --git a/projects/ngx-hal/src/public_api.ts b/projects/ngx-hal/src/public_api.ts index 4ad5eca..fd1bbad 100644 --- a/projects/ngx-hal/src/public_api.ts +++ b/projects/ngx-hal/src/public_api.ts @@ -13,6 +13,11 @@ export * from './lib/models/hal.model'; export * from './lib/classes/hal-document'; export * from './lib/classes/pagination'; +export * from './lib/classes/hal-storage/hal-storage'; +export * from './lib/classes/hal-storage/etag-hal-storage'; +export * from './lib/classes/hal-storage/simple-hal-storage'; +export * from './lib/classes/hal-storage/cache-first-fetch-later.storage'; + export * from './lib/enums/cache-strategy.enum'; export * from './lib/interfaces/network-config.interface';