Skip to content

Commit b369463

Browse files
committed
feat(trakt-client): adds cache support to endpoints
1 parent 6236c7e commit b369463

File tree

6 files changed

+184
-21
lines changed

6 files changed

+184
-21
lines changed

src/models/trakt/trakt-client.model.ts

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TraktApiFilters } from '~/services/trakt-client/api/trakt-api.filters';
2+
import type { CacheStore } from '~/utils/cache.utils';
23
import type { CancellablePromise } from '~/utils/fetch.utils';
34
import type { HttpMethods } from '~/utils/http.utils';
45

@@ -32,6 +33,14 @@ export type TraktClientSettings = {
3233
useragent: string;
3334
};
3435

36+
/**
37+
* Trakt.tv API client options.
38+
*/
39+
export type TraktClientOptions = TraktClientSettings & {
40+
/** Optional cache store to manage cache read/write */
41+
cacheStore?: CacheStore<TraktApiResponse>;
42+
};
43+
3544
/**
3645
* By default, all methods will return minimal info for movies, shows, episodes, people, and users.
3746
* Minimal info is typically all you need to match locally cached items and includes the title, year, and ids.
@@ -116,6 +125,7 @@ export class TraktClientEndpoint<P extends TraktApiParams = Record<string, never
116125
body?: Record<string, boolean>;
117126
init?: TraktApiInit;
118127
validate?: (param: P) => boolean;
128+
cached: Omit<this, 'cached'> & ((param?: P, init?: TraktApiInit) => Promise<TraktApiResponse>);
119129

120130
constructor(template: TraktApiTemplate<P>) {
121131
this.method = template.method;
@@ -125,6 +135,7 @@ export class TraktClientEndpoint<P extends TraktApiParams = Record<string, never
125135
this.body = template.body;
126136

127137
this.validate = template.validate;
138+
this.cached = this;
128139
}
129140
}
130141

src/services/trakt-client/clients/base-trakt-client.test.ts

+27-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { traktClientSettings } from '../trakt-client.service';
88
import { BaseTraktClient, parseBody, parseResponse } from './base-trakt-client';
99

1010
import type { TraktClientAuthentication } from '../../../models/trakt/trakt-authentication.model';
11-
import type { TraktApiInit, TraktApiParams, TraktApiQuery, TraktApiTemplate } from '../../../models/trakt/trakt-client.model';
11+
import type { TraktApiInit, TraktApiParams, TraktApiQuery, TraktApiResponse, TraktApiTemplate } from '../../../models/trakt/trakt-client.model';
12+
import type { CacheStore } from '../../../utils/cache.utils';
1213
import type { CancellablePromise } from '../../../utils/fetch.utils';
1314
import type { Updater } from '../../../utils/observable.utils';
1415

@@ -31,7 +32,13 @@ class TestableTraktClient extends BaseTraktClient {
3132
}
3233

3334
describe('base-trakt-client.ts', () => {
34-
const client = new TestableTraktClient(traktClientSettings);
35+
const cacheStore: CacheStore<TraktApiResponse> = {
36+
get: vi.fn(),
37+
set: vi.fn(),
38+
clear: vi.fn(),
39+
delete: vi.fn(),
40+
};
41+
const client = new TestableTraktClient({ ...traktClientSettings, cacheStore });
3542
const auth: TraktClientAuthentication = {
3643
refresh_token: 'refresh_token',
3744
access_token: 'access_token',
@@ -63,6 +70,24 @@ describe('base-trakt-client.ts', () => {
6370
expect(client.auth).toBeDefined();
6471
});
6572

73+
describe('cache', () => {
74+
it('should delete a cached entry', async () => {
75+
expect.assertions(1);
76+
77+
await client.clearCache('key');
78+
79+
expect(cacheStore.delete).toHaveBeenCalledWith('key');
80+
});
81+
82+
it('should delete a cached entry', async () => {
83+
expect.assertions(1);
84+
85+
await client.clearCache();
86+
87+
expect(cacheStore.clear).toHaveBeenCalledWith();
88+
});
89+
});
90+
6691
describe('observers', () => {
6792
const authObserver = vi.fn();
6893
const callObserver = vi.fn();

src/services/trakt-client/clients/base-trakt-client.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import type {
66
TraktApiRequest,
77
TraktApiResponse,
88
TraktApiTemplate,
9+
TraktClientOptions,
910
TraktClientSettings,
1011
} from '~/models/trakt/trakt-client.model';
1112

13+
import type { CacheStore } from '~/utils/cache.utils';
1214
import type { Primitive } from '~/utils/typescript.utils';
1315

1416
import { TraktApiHeaders } from '~/models/trakt/trakt-client.model';
@@ -132,10 +134,22 @@ export const parseResponse = <T>(response: Response): TraktApiResponse<T> => {
132134
* @class BaseTraktClient
133135
*/
134136
export class BaseTraktClient {
137+
protected _cache: CacheStore<TraktApiResponse>;
135138
protected _settings: TraktClientSettings;
136139
protected _authentication: ObservableState<TraktClientAuthentication>;
137140
protected _callListeners: Observable<TraktApiQuery<unknown>>;
138141

142+
/**
143+
* Clears the cache entry for the specified key.
144+
* If no key is provided, clears the entire cache.
145+
*
146+
* @param key - The cache key.
147+
*/
148+
clearCache(key?: string) {
149+
if (key) return this._cache?.delete(key);
150+
return this._cache?.clear();
151+
}
152+
139153
/**
140154
* Gets the authentication information.
141155
*
@@ -191,10 +205,11 @@ export class BaseTraktClient {
191205
};
192206
}
193207

194-
constructor(settings: TraktClientSettings, authentication = {}) {
208+
constructor({ cacheStore, ...settings }: TraktClientOptions, authentication = {}) {
195209
this._settings = settings;
196210
this._authentication = new ObservableState(authentication);
197211
this._callListeners = new Observable();
212+
this._cache = cacheStore ?? new Map();
198213
}
199214

200215
/**

src/services/trakt-client/clients/trakt-client.test.ts

+81
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { traktClientSettings } from '../trakt-client.service';
99
import { TraktClient } from './trakt-client';
1010

1111
import type { TraktAuthentication, TraktDeviceAuthentication } from '../../../models/trakt/trakt-authentication.model';
12+
import type { TraktApiResponse } from '../../../models/trakt/trakt-client.model';
13+
import type { CacheStore } from '../../../utils/cache.utils';
1214

1315
import type { RecursiveRecord } from '../../../utils/typescript.utils';
1416

@@ -28,6 +30,7 @@ describe('trakt-client.ts', () => {
2830

2931
afterEach(async () => {
3032
await traktClient.importAuthentication({});
33+
await traktClient.clearCache();
3134

3235
vi.clearAllMocks();
3336
});
@@ -75,6 +78,84 @@ describe('trakt-client.ts', () => {
7578
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/shows', traktClientSettings.endpoint).toString(), payload);
7679
});
7780

81+
describe('cache', () => {
82+
it('should not cache calls', async () => {
83+
expect.assertions(2);
84+
85+
await traktClient.certifications({ type: 'movies' });
86+
await traktClient.certifications({ type: 'movies' });
87+
await traktClient.certifications({ type: 'movies' });
88+
89+
expect(fetch).toHaveBeenCalledTimes(3);
90+
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/movies', traktClientSettings.endpoint).toString(), payload);
91+
});
92+
93+
it('should cache subsequent calls', async () => {
94+
expect.assertions(2);
95+
96+
await traktClient.certifications.cached({ type: 'movies' });
97+
await traktClient.certifications.cached({ type: 'movies' });
98+
await traktClient.certifications.cached({ type: 'movies' });
99+
100+
expect(fetch).toHaveBeenCalledTimes(1);
101+
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/movies', traktClientSettings.endpoint).toString(), payload);
102+
});
103+
104+
it('should ignore cache if cache cleared', async () => {
105+
expect.assertions(2);
106+
107+
await traktClient.certifications.cached({ type: 'movies' });
108+
await traktClient.certifications.cached({ type: 'movies' });
109+
await traktClient.clearCache();
110+
await traktClient.certifications.cached({ type: 'movies' });
111+
112+
expect(fetch).toHaveBeenCalledTimes(2);
113+
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/movies', traktClientSettings.endpoint).toString(), payload);
114+
});
115+
116+
it('should clear cache after error', async () => {
117+
expect.assertions(3);
118+
119+
const error = new Error('Error');
120+
fetch.mockRejectedValueOnce(error);
121+
122+
let err: unknown;
123+
try {
124+
await traktClient.certifications.cached({ type: 'movies' });
125+
} catch (e) {
126+
err = e;
127+
} finally {
128+
expect(err).toBe(error);
129+
}
130+
await traktClient.certifications.cached({ type: 'movies' });
131+
await traktClient.certifications.cached({ type: 'movies' });
132+
133+
expect(fetch).toHaveBeenCalledTimes(2);
134+
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/movies', traktClientSettings.endpoint).toString(), payload);
135+
});
136+
137+
it('should ignore cache if cache expired', async () => {
138+
expect.assertions(2);
139+
140+
const cacheStore: CacheStore<TraktApiResponse> = new Map();
141+
cacheStore.retention = 100;
142+
const _traktClient = new TraktClient({ ...traktClientSettings, cacheStore });
143+
144+
await _traktClient.certifications.cached({ type: 'movies' });
145+
await _traktClient.certifications.cached({ type: 'movies' });
146+
147+
// Wait for cache to expire
148+
await new Promise(resolve => {
149+
setTimeout(resolve, 200);
150+
});
151+
152+
await _traktClient.certifications.cached({ type: 'movies' });
153+
154+
expect(fetch).toHaveBeenCalledTimes(2);
155+
expect(fetch).toHaveBeenCalledWith(new URL('/certifications/movies', traktClientSettings.endpoint).toString(), payload);
156+
});
157+
});
158+
78159
const deviceAuthentication: TraktDeviceAuthentication = {
79160
device_code: 'device_code',
80161
user_code: 'user_code',

src/services/trakt-client/clients/trakt-client.ts

+35-18
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
TraktApiParams,
1919
TraktApiResponse,
2020
TraktClientEndpointCall,
21+
TraktClientOptions,
2122
TraktClientSettings,
2223
} from '~/models/trakt/trakt-client.model';
2324

@@ -90,11 +91,40 @@ class TraktApi extends BaseTraktClient implements ITraktEndpoints {
9091
Object.entries(client).forEach(([endpoint, template]) => {
9192
if (isTraktApiTemplate(template) && isTraktApiTemplate(client[endpoint])) {
9293
const fn: TraktClientEndpointCall = (param, init) => this._call(template, param, init);
94+
95+
const cachedFn: TraktClientEndpointCall = async (param, init) => {
96+
const key = JSON.stringify({ param, init });
97+
const cached = await this._cache.get(key);
98+
if (cached) {
99+
if (!this._cache.retention) return cached.value;
100+
const expires = cached.cachedAt + this._cache.retention;
101+
if (expires > Date.now()) return cached.value;
102+
}
103+
try {
104+
const result = await fn(param, init);
105+
await this._cache.set(key, {
106+
cachedAt: Date.now(),
107+
value: result,
108+
});
109+
return result;
110+
} catch (error) {
111+
this._cache.delete(key);
112+
throw error;
113+
}
114+
};
115+
93116
Object.entries(client[endpoint]).forEach(([key, value]) => {
94-
Object.defineProperty(fn, key, { value });
117+
if (key === 'cached') {
118+
Object.defineProperty(fn, 'cached', { value: cachedFn });
119+
} else {
120+
Object.defineProperty(fn, key, { value });
121+
Object.defineProperty(cachedFn, key, { value });
122+
}
95123
});
96-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic typing
97-
client[endpoint] = fn as any;
124+
125+
Object.defineProperty(fn, 'cached', { value: cachedFn });
126+
127+
client[endpoint] = fn as (typeof client)[typeof endpoint];
98128
} else {
99129
client[endpoint] = this.bindToEndpoint(client[endpoint] as ITraktApi);
100130
}
@@ -118,21 +148,8 @@ class TraktApi extends BaseTraktClient implements ITraktEndpoints {
118148
export class TraktClient extends TraktApi {
119149
private polling: ReturnType<typeof setTimeout> | undefined;
120150

121-
constructor({
122-
client_id,
123-
client_secret,
124-
redirect_uri,
125-
126-
useragent,
127-
endpoint,
128-
}: TraktClientSettings) {
129-
super({
130-
client_id,
131-
client_secret,
132-
redirect_uri,
133-
useragent,
134-
endpoint,
135-
});
151+
constructor(options: TraktClientOptions) {
152+
super(options);
136153
}
137154

138155
/**

src/utils/cache.utils.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type CacheStoreEntity<T> = {
2+
value: T;
3+
cachedAt: number;
4+
accessedAt?: number;
5+
};
6+
7+
export type CacheStore<T> = {
8+
get(key: string): CacheStoreEntity<T> | Promise<CacheStoreEntity<T>> | undefined;
9+
set(key: string, value: CacheStoreEntity<T>): CacheStore<T> | Promise<CacheStore<T>>;
10+
delete(key: string): boolean | Promise<boolean>;
11+
clear(): void;
12+
/** the duration in milliseconds after which the cache will be cleared */
13+
retention?: number;
14+
};

0 commit comments

Comments
 (0)