From ea6426f3241e580296ca20a5885cbdab7bf7a630 Mon Sep 17 00:00:00 2001 From: Mihael Safaric Date: Tue, 17 Nov 2020 13:40:24 +0100 Subject: [PATCH 1/8] Implement cacheable local storage --- .../cache-first-fetch-later.storage.ts | 28 ++++++ .../classes/hal-storage/etag-hal-storage.ts | 2 +- .../hal-storage/hal-storage-factory.ts | 13 ++- .../lib/classes/hal-storage/hal-storage.ts | 12 +++ .../decorators/datastore-config.decorator.ts | 1 + .../src/lib/enums/cache-strategy.enum.ts | 2 + .../interfaces/datastore-options.interface.ts | 2 + .../services/datastore/datastore.service.ts | 96 ++++++++++++++----- projects/ngx-hal/src/public_api.ts | 5 + 9 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 projects/ngx-hal/src/lib/classes/hal-storage/cache-first-fetch-later.storage.ts 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 b706315..7c7362f 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 bbee212..75dfa69 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 ab21a28..3eeafcb 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); + // tslint:disable-next-line + private _storage: HalStorage; // set by Config decorator + private internalStorage = createHalStorage(this.cacheStrategy, this.halStorage); protected httpParamsOptions?: object; public paginationClass: PaginationConstructor; @@ -280,7 +283,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( @@ -307,6 +310,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, @@ -676,32 +710,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); + const { cleanUrl, requestOptions: options, urlWithParams } = this.extractRequestInfo(url, requestOptions); - this.storage.enrichRequestOptions(url, options); - - const templatedUrl: string = (new UriTemplate(url)).fill(options.params); - - 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); @@ -713,6 +731,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 { @@ -881,6 +927,10 @@ export class DatastoreService { return this._cacheStrategy; } + private get halStorage(): HalStorage { + return this._storage; + } + public createModel(modelClass: ModelConstructor, recordData: object = {}): T { const rawRecordData: object = Object.assign({}, recordData); rawRecordData[EMBEDDED_PROPERTY_NAME] = Object.assign({}, recordData, recordData[EMBEDDED_PROPERTY_NAME]); 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'; From 33b1dae02514cfb44266e374b2536edace52021e Mon Sep 17 00:00:00 2001 From: Mihael Safaric Date: Wed, 18 Nov 2020 21:40:46 +0100 Subject: [PATCH 2/8] Bump version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index 4c0420c..8f4a627 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.0.1", + "version": "2.1.1-beta", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT", From 149152bb09958c7b457b957cb032a35b1dbbdadb Mon Sep 17 00:00:00 2001 From: Mihael Safaric Date: Mon, 18 Jan 2021 11:36:24 +0100 Subject: [PATCH 3/8] Bump version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index 8f4a627..727ae3b 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.1.1-beta", + "version": "2.1.2-beta", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT", From 94492ffab7038c8338deccb8c21a9e11e09d6706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mihael=20S=CC=8Cafaric=CC=81?= Date: Wed, 3 Nov 2021 13:54:32 +0100 Subject: [PATCH 4/8] Bump version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index 727ae3b..c0c57e4 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.1.2-beta", + "version": "2.1.4-beta", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT", From 1962884d34eaced9da665723e5684d8a82784fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mihael=20S=CC=8Cafaric=CC=81?= Date: Wed, 3 Nov 2021 14:14:02 +0100 Subject: [PATCH 5/8] Handle decoding url params for creating storage key --- .../ngx-hal/src/lib/services/datastore/datastore.service.ts | 5 +++-- .../src/lib/utils/get-query-params/get-query-params.util.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) 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 3eeafcb..1e0b712 100644 --- a/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts +++ b/projects/ngx-hal/src/lib/services/datastore/datastore.service.ts @@ -23,7 +23,7 @@ import { createHalStorage } from '../../classes/hal-storage/hal-storage-factory' import { RequestsOptions } from '../../interfaces/requests-options.interface'; import { makeQueryParamsString } from '../../helpers/make-query-params-string/make-query-params-string.helper'; import { removeQueryParams } from '../../utils/remove-query-params/remove-query-params.util'; -import { getQueryParams } from '../../utils/get-query-params/get-query-params.util'; +import { getQueryParams, decodeURIComponentWithErrorHandling } from '../../utils/get-query-params/get-query-params.util'; import { isHalModelInstance } from '../../helpers/is-hal-model-instance.ts/is-hal-model-instance.helper'; import { makeHttpParams } from '../../helpers/make-http-params/make-http-params.helper'; import { CustomOptions } from '../../interfaces/custom-options.interface'; @@ -327,7 +327,8 @@ export class DatastoreService { 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)); + const cachedResoucesFromUrl = this.storage.get(decodeURIComponentWithErrorHandling(url)) || + this.storage.get(decodeURIComponentWithErrorHandling(urlWithParams)); return this.storage.makeGetRequestWrapper( { cleanUrl, urlWithParams, originalUrl: url }, cachedResoucesFromUrl, diff --git a/projects/ngx-hal/src/lib/utils/get-query-params/get-query-params.util.ts b/projects/ngx-hal/src/lib/utils/get-query-params/get-query-params.util.ts index 74a8a25..89de626 100644 --- a/projects/ngx-hal/src/lib/utils/get-query-params/get-query-params.util.ts +++ b/projects/ngx-hal/src/lib/utils/get-query-params/get-query-params.util.ts @@ -29,7 +29,7 @@ export function getQueryParams(url: string): object { return queryParams; } -function decodeURIComponentWithErrorHandling(value: string): string { +export function decodeURIComponentWithErrorHandling(value: string): string { try { return decodeURIComponent(value); } catch (e) { From 4872d3f1dc3f8be7330cd1c53a8a7f6cc1c2a613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mihael=20S=CC=8Cafaric=CC=81?= Date: Wed, 3 Nov 2021 14:22:12 +0100 Subject: [PATCH 6/8] Bump version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index c0c57e4..33b6598 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.1.4-beta", + "version": "2.1.5-beta", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT", From 75af401bb4ad959fe395ec26a8a383da76b8b5db Mon Sep 17 00:00:00 2001 From: Mihael Safaric Date: Tue, 5 Apr 2022 17:45:41 +0200 Subject: [PATCH 7/8] Bump version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index 33b6598..f0541a9 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.1.5-beta", + "version": "2.1.7-beta", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT", From e35870ca8545af4390ea3eb2305eacad94560790 Mon Sep 17 00:00:00 2001 From: Mihael Safaric Date: Thu, 11 Aug 2022 13:21:38 +0200 Subject: [PATCH 8/8] Revert lib version --- projects/ngx-hal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/ngx-hal/package.json b/projects/ngx-hal/package.json index f0541a9..4c0420c 100644 --- a/projects/ngx-hal/package.json +++ b/projects/ngx-hal/package.json @@ -1,6 +1,6 @@ { "name": "ngx-hal", - "version": "2.1.7-beta", + "version": "2.0.1", "description": "Angular library for supporting HAL format APIs", "author": "Infinum ", "license": "MIT",