Skip to content

Commit 0b90016

Browse files
author
Denis Khrunov
committed
Added @Cacheable() decorator
1 parent dbd5e6b commit 0b90016

File tree

16 files changed

+418
-4
lines changed

16 files changed

+418
-4
lines changed

projects/ngx-http-decorators/README.md

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ A library to simplify working with Http requests by describing these requests in
1111
- [Request with Body](#request-with-body)
1212
- [Request with Query Params](#request-with-query-params)
1313
- [Request with Headers](#request-with-headers)
14-
- [Reqeust with HttpContext](#request-with-httpcontext)
14+
- [Reqeust with HttpContext](#reqeust-with-httpcontext)
15+
- [Cacheable request](#cacheable-request)
1516
- [Decorators](#decorators)
1617
- [@HttpController()](#httpcontroller)
1718
- [@Get()](#get)
@@ -25,6 +26,7 @@ A library to simplify working with Http requests by describing these requests in
2526
- [@Body()](#body)
2627
- [@Header()](#header)
2728
- [@Context()](#context)
29+
- [@Cacheable()](#cacheable)
2830

2931
## Settings
3032

@@ -302,6 +304,27 @@ export class PostService {
302304
}
303305
```
304306

307+
### Cacheable request
308+
309+
To cache the result of a query, we can use the `@Cacheable()` decorator.
310+
The `@Cacheable()` decorator has several options, [more on the @Cacheable() decorator](#cacheable),
311+
but in most cases a decorator with no options is sufficient.
312+
313+
```typescript
314+
@Injectable({
315+
providedIn: 'root',
316+
})
317+
@HttpController('/api/posts')
318+
export class PostService {
319+
320+
@Cacheable()
321+
@Get()
322+
public getAll(): Observable<Post[]> {
323+
return request(this.getAll);
324+
}
325+
}
326+
```
327+
305328
## Decorators
306329

307330
Decorators documentation.
@@ -561,3 +584,100 @@ export class PostService {
561584
}
562585
}
563586
```
587+
588+
### @Cacheable()
589+
590+
Method decorator. Caching the result of the request, the cached request does not make a repeated http call.
591+
592+
This decorator has optional options:
593+
- __`key`__ - The key under which the cached value will be stored, by default use this `'{{className}}_{{methodName}}_{{args}}'`. Can be just a string or a dynamic function that returns a key.
594+
- __`write`__ - A function that describes how to write http response data to the cache.
595+
- __`read`__ - A function that describes how to read cached data (if it exists) from the cache.
596+
- __`cache`__ - specifies a different cache store for the decorated method, accepts `InjectionToken<ICache>`, before using another cache store, you must provide it to the `NgxHttpDecoratorsModule`.
597+
598+
```typescript
599+
@Injectable({
600+
providedIn: 'root',
601+
})
602+
@HttpController('/api/posts')
603+
export class PostService {
604+
605+
@Cacheable({
606+
key: 'GET_ALL_POSTS',
607+
write: ({ incoming, existing}) => ({ ...existing, ...incoming })
608+
read: ({ args, existing }) => {
609+
if (!existing) {
610+
// Has no cached values
611+
return;
612+
}
613+
614+
// There may be some logic here to retrieve data from the cache
615+
616+
return existing;
617+
}
618+
})
619+
@Get()
620+
public getAll(): Observable<Post[]> {
621+
return request(this.getAll);
622+
}
623+
}
624+
```
625+
626+
You can also create your own cache store by creating a service and implementing the `ICache` interface.
627+
628+
Cache service:
629+
```typescript
630+
const MY_HTT_CACHE_TOKEN = new InjectionToken<ICache>(
631+
'MY_HTT_CACHE_TOKEN',
632+
{
633+
providedIn: 'root',
634+
factory: () => new MyHttpCache()
635+
}
636+
);
637+
638+
@Injectable()
639+
export class MyHttpCache implements ICache {
640+
private readonly cache = new Map<string, unknown>();
641+
642+
public set<T>(key: string, value: T): void {
643+
this.cache.set(key, value);
644+
}
645+
646+
public get<T>(key: string): T | undefined {
647+
return this.cache.get(key) as T;
648+
}
649+
650+
public delete(key: string | string[]): void {
651+
if (Array.isArray(key)) {
652+
key.forEach((k) => this.cache.delete(k));
653+
} else {
654+
this.cache.delete(key);
655+
}
656+
}
657+
658+
public clear(): void {
659+
this.cache.clear();
660+
}
661+
}
662+
```
663+
664+
Used in @Cacheable() decaorator:
665+
666+
```typescript
667+
@Injectable({
668+
providedIn: 'root',
669+
})
670+
@HttpController('/api/posts')
671+
export class PostService {
672+
673+
@Cacheable({
674+
key: 'GET_ALL_POSTS',
675+
cache: MY_HTT_CACHE_TOKEN
676+
})
677+
@Get()
678+
public getAll(): Observable<Post[]> {
679+
return request(this.getAll);
680+
}
681+
}
682+
```
683+

projects/ngx-http-decorators/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ngx-http-decorators",
3-
"version": "15.0.0",
3+
"version": "15.1.0",
44
"license": "MIT",
55
"repository": {
66
"type": "GitHub",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface ICache {
2+
/**
3+
* Save value by key in the cache.
4+
*/
5+
set<T>(key: string, value: T): void;
6+
7+
/**
8+
* Get cached value by key.
9+
*/
10+
get<T>(key: string): T | undefined;
11+
12+
/**
13+
* Delete cached value by key.
14+
*/
15+
delete(key: string | string[]): void;
16+
17+
/**
18+
* Clear all values in the cache.
19+
*/
20+
clear(): void;
21+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './cache.interface';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { inject, InjectionToken } from '@angular/core';
2+
import { NgxHttpDecoratorsModule } from '../../ngx-http-decorators.module';
3+
import { ICache } from '../abstractions';
4+
import { NgxHttpInMemoryCache } from '../in-memory-cache.service';
5+
6+
export const DEFAULT_CACHE = new InjectionToken<ICache>(
7+
'Nhd/DEFAULT_CACHE',
8+
{
9+
providedIn: NgxHttpDecoratorsModule,
10+
factory: () => inject(NgxHttpInMemoryCache)
11+
}
12+
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './default-cache.token';
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/* eslint-disable prefer-arrow/prefer-arrow-functions */
2+
/* eslint-disable jsdoc/require-jsdoc */
3+
/* eslint-disable require-jsdoc */
4+
import { inject } from '@angular/core';
5+
import { Observable, of, shareReplay, tap } from 'rxjs';
6+
import { ICache } from '../../abstractions';
7+
import { DEFAULT_CACHE } from '../../constants';
8+
import { CacheableOptions } from './types/cacheable-options.type';
9+
10+
const generateCacheKey = (
11+
target: object,
12+
methodName: string | symbol,
13+
args: unknown[]
14+
): string => {
15+
const cacheKeyPrefix = `${target.constructor.name}_${methodName.toString()}`;
16+
const key = `${cacheKeyPrefix}_${JSON.stringify(args)}`;
17+
18+
return key;
19+
};
20+
21+
function resolveCacheStorage<TIncoming, TExisting = TIncoming>(
22+
options?: Pick<CacheableOptions<TIncoming, TExisting>, 'cache'>
23+
): ICache {
24+
if (options?.cache) {
25+
return inject(options.cache);
26+
}
27+
28+
return inject(DEFAULT_CACHE);
29+
}
30+
31+
const resolveCacheKey = <TIncoming, TExisting = TIncoming>(
32+
target: object,
33+
methodName: string | symbol,
34+
args: unknown[],
35+
options?: Pick<CacheableOptions<TIncoming, TExisting>, 'key'>
36+
): string => {
37+
if (options?.key) {
38+
if (typeof options.key === 'function') {
39+
return options.key({ args });
40+
}
41+
42+
return options?.key;
43+
}
44+
45+
return generateCacheKey(target, methodName, args);
46+
};
47+
48+
const readCachedValue = <TIncoming, TExisting = TIncoming>(
49+
args: unknown[],
50+
cache: ICache,
51+
cacheKey: string,
52+
options?: Pick<CacheableOptions<TIncoming, TExisting>, 'read'>
53+
): TIncoming | undefined => {
54+
if (options?.read) {
55+
const existing = cache.get<TExisting>(cacheKey);
56+
return options.read({ args, existing });
57+
}
58+
59+
return cache.get<TIncoming>(cacheKey);
60+
};
61+
62+
const writeCacheValue = <TIncoming, TExisting = TIncoming>(
63+
args: unknown[],
64+
incoming: TIncoming,
65+
cache: ICache,
66+
cacheKey: string,
67+
options?: Pick<CacheableOptions<TIncoming, TExisting>, 'write'>
68+
): void => {
69+
if (options?.write) {
70+
const existing = cache.get<TExisting>(cacheKey);
71+
const value = options.write({ args, incoming, existing });
72+
cache.set<TExisting>(cacheKey, value);
73+
} else {
74+
cache.set<TIncoming>(cacheKey, incoming);
75+
}
76+
};
77+
78+
/**
79+
* Caches the result of an http request. On subsequent calls to the decorated method,
80+
* data will be returned from the cache until the cache is invalidated (reset).
81+
*----------------
82+
*
83+
* __IMPORTANT:__
84+
*
85+
* 1. Use a subscription to a cached request with a `take(1)` operator, or with an unsubscribe, because the original `Observable`
86+
* returned from `HttpClient` turns from `Cold Observable` to `Hot Observable`,
87+
* if `Cold Observable` creates a new thread (new request) for each subscription, then `Hot Observable` fumbles this subscription.
88+
*
89+
* 2. The method being decorated should not have side effects, because after caching the body of this function will not be re-executed,
90+
* because the value of the request is immediately taken from their cache.
91+
*
92+
* @example
93+
* ```typescript
94+
* ...
95+
* \@Cacheable<Response, UsersService>({ key: 'CustomKey', })
96+
* public request(): Observable<Response> { }
97+
* ...
98+
* ```
99+
* ----------------
100+
* @param options Опцци кеширования.
101+
*/
102+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions, require-jsdoc, max-lines-per-function
103+
export function Cacheable<TIncoming, TExisting = TIncoming>(
104+
options?: CacheableOptions<TIncoming, TExisting>
105+
): MethodDecorator {
106+
// eslint-disable-next-line max-lines-per-function
107+
return (
108+
target: object,
109+
methodName: string | symbol,
110+
descriptor: TypedPropertyDescriptor<any>
111+
) => {
112+
if (!(descriptor?.value instanceof Function)) {
113+
throw new TypeError(
114+
'"Cacheable" decorator can only be applied to a class method that returns an Observable'
115+
);
116+
}
117+
118+
const originalMethod = descriptor.value as (
119+
...args: unknown[]
120+
) => Observable<TIncoming>;
121+
122+
descriptor.value = function (...args: unknown[]): Observable<TIncoming> {
123+
const cache = resolveCacheStorage.bind(target)<TIncoming, TExisting>(
124+
options
125+
);
126+
const cacheKey = resolveCacheKey<TIncoming, TExisting>(
127+
target,
128+
methodName,
129+
args,
130+
options
131+
);
132+
const cachedValue = readCachedValue<TIncoming, TExisting>(
133+
args,
134+
cache,
135+
cacheKey,
136+
options
137+
);
138+
139+
if (cachedValue) {
140+
return of(cachedValue);
141+
}
142+
143+
const originalRequest = originalMethod.apply(this, args);
144+
145+
if (!(originalRequest instanceof Observable)) {
146+
throw new TypeError(
147+
`"Cacheable": Decorated method "${methodName.toString()}" in class "${
148+
target.constructor.name
149+
}" must return an Observable value.`
150+
);
151+
}
152+
153+
return originalRequest.pipe(
154+
tap((incoming) =>
155+
writeCacheValue(args, incoming, cache, cacheKey, options)
156+
),
157+
shareReplay(1)
158+
);
159+
};
160+
161+
return descriptor;
162+
};
163+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from './cacheable.decorator';
2+
export * from './types/cache-key.type';
3+
export * from './types/cache-read.type';
4+
export * from './types/cache-write.type';
5+
export * from './types/cacheable-options.type';
6+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type CacheKeyFunctionOptions = {
2+
args: unknown[];
3+
};
4+
5+
export type CacheKeyFunction = (options: CacheKeyFunctionOptions) => string;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type CacheReadFunctionOptions<TExisting> = {
2+
args: unknown[];
3+
existing: TExisting | undefined;
4+
};
5+
6+
export type CacheRead<TIncoming, TExisting = TIncoming> = (
7+
options: CacheReadFunctionOptions<TExisting>
8+
) => TIncoming | undefined;

0 commit comments

Comments
 (0)