perspectapi-ts-sdk 1.5.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/dist/index.d.mts +154 -22
- package/dist/index.d.ts +154 -22
- package/dist/index.js +510 -70
- package/dist/index.mjs +507 -70
- package/package.json +1 -1
- package/src/cache/cache-manager.ts +302 -0
- package/src/cache/in-memory-adapter.ts +49 -0
- package/src/cache/noop-adapter.ts +27 -0
- package/src/cache/types.ts +78 -0
- package/src/client/api-keys-client.ts +3 -2
- package/src/client/auth-client.ts +3 -2
- package/src/client/base-client.ts +88 -1
- package/src/client/categories-client.ts +34 -10
- package/src/client/checkout-client.ts +3 -2
- package/src/client/contact-client.ts +3 -2
- package/src/client/content-client.ts +59 -10
- package/src/client/newsletter-client.ts +4 -3
- package/src/client/organizations-client.ts +3 -2
- package/src/client/products-client.ts +115 -22
- package/src/client/sites-client.ts +3 -2
- package/src/client/webhooks-client.ts +3 -2
- package/src/index.ts +5 -0
- package/src/perspect-api-client.ts +14 -11
- package/src/types/index.ts +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheManager orchestrates cache get/set/invalidate behaviour using a pluggable adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { InMemoryCacheAdapter } from './in-memory-adapter';
|
|
6
|
+
import { NoopCacheAdapter } from './noop-adapter';
|
|
7
|
+
import type {
|
|
8
|
+
CacheAdapter,
|
|
9
|
+
CacheConfig,
|
|
10
|
+
CacheEntry,
|
|
11
|
+
CacheInvalidateOptions,
|
|
12
|
+
CacheKeyPart,
|
|
13
|
+
CachePolicy,
|
|
14
|
+
CacheSetOptions,
|
|
15
|
+
CacheManagerOptions,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
const TAG_PREFIX = '__tag__';
|
|
19
|
+
|
|
20
|
+
export class CacheManager {
|
|
21
|
+
private adapter: CacheAdapter;
|
|
22
|
+
private readonly defaultTtlSeconds: number;
|
|
23
|
+
private readonly keyPrefix: string;
|
|
24
|
+
private readonly enabled: boolean;
|
|
25
|
+
|
|
26
|
+
constructor(config?: CacheConfig) {
|
|
27
|
+
const defaultOptions: CacheManagerOptions = {
|
|
28
|
+
defaultTtlSeconds: 300,
|
|
29
|
+
keyPrefix: 'perspectapi',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const mergedConfig = {
|
|
33
|
+
...defaultOptions,
|
|
34
|
+
...config,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (config && config.enabled === false) {
|
|
38
|
+
this.enabled = false;
|
|
39
|
+
this.adapter = new NoopCacheAdapter();
|
|
40
|
+
} else if (config && config.adapter) {
|
|
41
|
+
this.enabled = true;
|
|
42
|
+
this.adapter = config.adapter;
|
|
43
|
+
} else if (config) {
|
|
44
|
+
// Cache config supplied but no adapter; fall back to in-memory caching.
|
|
45
|
+
this.enabled = true;
|
|
46
|
+
this.adapter = new InMemoryCacheAdapter();
|
|
47
|
+
} else {
|
|
48
|
+
this.enabled = false;
|
|
49
|
+
this.adapter = new NoopCacheAdapter();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.defaultTtlSeconds = mergedConfig.defaultTtlSeconds ?? 300;
|
|
53
|
+
this.keyPrefix = mergedConfig.keyPrefix ?? 'perspectapi';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isEnabled(): boolean {
|
|
57
|
+
return this.enabled;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getKeyPrefix(): string {
|
|
61
|
+
return this.keyPrefix;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
buildKey(parts: CacheKeyPart[]): string {
|
|
65
|
+
const normalized = parts
|
|
66
|
+
.flatMap(part => this.normalizeKeyPart(part))
|
|
67
|
+
.filter(part => part !== undefined && part !== null && part !== '');
|
|
68
|
+
|
|
69
|
+
return normalized.join(':');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getOrSet<T>(
|
|
73
|
+
key: string,
|
|
74
|
+
resolveValue: () => Promise<T>,
|
|
75
|
+
policy?: CachePolicy
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
if (!this.enabled || policy?.skipCache) {
|
|
78
|
+
console.log('[Cache] Cache disabled or skipped', { key, enabled: this.enabled, skipCache: policy?.skipCache });
|
|
79
|
+
const value = await resolveValue();
|
|
80
|
+
|
|
81
|
+
if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
|
|
82
|
+
await this.set(key, value, policy);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const namespacedKey = this.namespacedKey(key);
|
|
89
|
+
const cachedRaw = await this.adapter.get(namespacedKey);
|
|
90
|
+
|
|
91
|
+
if (cachedRaw) {
|
|
92
|
+
const entry = this.deserialize<T>(cachedRaw);
|
|
93
|
+
|
|
94
|
+
if (!entry.expiresAt || entry.expiresAt > Date.now()) {
|
|
95
|
+
console.log('[Cache] ✓ HIT', { key, tags: entry.tags });
|
|
96
|
+
return entry.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Expired entry
|
|
100
|
+
console.log('[Cache] ✗ EXPIRED', { key, expiresAt: new Date(entry.expiresAt) });
|
|
101
|
+
await this.adapter.delete(namespacedKey);
|
|
102
|
+
if (entry.tags?.length) {
|
|
103
|
+
await this.removeKeyFromTags(namespacedKey, entry.tags);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
console.log('[Cache] ✗ MISS', { key });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const value = await resolveValue();
|
|
110
|
+
await this.set(key, value, policy);
|
|
111
|
+
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
|
116
|
+
if (!this.enabled || options?.ttlSeconds === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const namespacedKey = this.namespacedKey(key);
|
|
121
|
+
const ttlSeconds = options?.ttlSeconds ?? this.defaultTtlSeconds;
|
|
122
|
+
|
|
123
|
+
const entry: CacheEntry<T> = {
|
|
124
|
+
value,
|
|
125
|
+
expiresAt: ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : undefined,
|
|
126
|
+
tags: options?.tags,
|
|
127
|
+
metadata: options?.metadata,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
console.log('[Cache] SET', { key, ttlSeconds, tags: options?.tags });
|
|
131
|
+
|
|
132
|
+
await this.adapter.set(namespacedKey, this.serialize(entry), {
|
|
133
|
+
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (options?.tags?.length) {
|
|
137
|
+
await this.registerKeyTags(namespacedKey, options.tags);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async delete(key: string): Promise<void> {
|
|
142
|
+
if (!this.enabled) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const namespacedKey = this.namespacedKey(key);
|
|
146
|
+
await this.adapter.delete(namespacedKey);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async invalidate(options: CacheInvalidateOptions): Promise<void> {
|
|
150
|
+
if (!this.enabled) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let totalInvalidated = 0;
|
|
155
|
+
|
|
156
|
+
if (options.keys?.length) {
|
|
157
|
+
const namespacedKeys = options.keys.map(key => this.namespacedKey(key));
|
|
158
|
+
console.log('[Cache] INVALIDATE by keys', { count: options.keys.length, keys: options.keys });
|
|
159
|
+
if (this.adapter.deleteMany) {
|
|
160
|
+
await this.adapter.deleteMany(namespacedKeys);
|
|
161
|
+
} else {
|
|
162
|
+
await Promise.all(namespacedKeys.map(key => this.adapter.delete(key)));
|
|
163
|
+
}
|
|
164
|
+
totalInvalidated += options.keys.length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (options.tags?.length) {
|
|
168
|
+
console.log('[Cache] INVALIDATE by tags', { tags: options.tags });
|
|
169
|
+
await Promise.all(
|
|
170
|
+
options.tags.map(async tag => {
|
|
171
|
+
const tagKey = this.tagKey(tag);
|
|
172
|
+
const payload = await this.adapter.get(tagKey);
|
|
173
|
+
if (!payload) {
|
|
174
|
+
console.log('[Cache] No entries for tag', { tag });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const keys = this.deserializeTagSet(payload);
|
|
179
|
+
if (keys.length) {
|
|
180
|
+
console.log('[Cache] Invalidating entries for tag', { tag, count: keys.length });
|
|
181
|
+
if (this.adapter.deleteMany) {
|
|
182
|
+
await this.adapter.deleteMany(keys);
|
|
183
|
+
} else {
|
|
184
|
+
await Promise.all(keys.map(key => this.adapter.delete(key)));
|
|
185
|
+
}
|
|
186
|
+
totalInvalidated += keys.length;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await this.adapter.delete(tagKey);
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log('[Cache] ✓ INVALIDATED', { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private namespacedKey(key: string): string {
|
|
198
|
+
return `${this.keyPrefix}:${key}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private tagKey(tag: string): string {
|
|
202
|
+
return this.namespacedKey(`${TAG_PREFIX}:${tag}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private serialize(entry: CacheEntry): string {
|
|
206
|
+
return JSON.stringify(entry);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private deserialize<T>(payload: string): CacheEntry<T> {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(payload) as CacheEntry<T>;
|
|
212
|
+
} catch {
|
|
213
|
+
return { value: payload as unknown as T };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private deserializeTagSet(payload: string): string[] {
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(payload);
|
|
220
|
+
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async registerKeyTags(namespacedKey: string, tags: string[]): Promise<void> {
|
|
227
|
+
await Promise.all(
|
|
228
|
+
tags.map(async tag => {
|
|
229
|
+
const tagKey = this.tagKey(tag);
|
|
230
|
+
const existingPayload = await this.adapter.get(tagKey);
|
|
231
|
+
const keys = existingPayload ? this.deserializeTagSet(existingPayload) : [];
|
|
232
|
+
|
|
233
|
+
if (!keys.includes(namespacedKey)) {
|
|
234
|
+
keys.push(namespacedKey);
|
|
235
|
+
await this.adapter.set(tagKey, JSON.stringify(keys));
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async removeKeyFromTags(namespacedKey: string, tags: string[]): Promise<void> {
|
|
242
|
+
await Promise.all(
|
|
243
|
+
tags.map(async tag => {
|
|
244
|
+
const tagKey = this.tagKey(tag);
|
|
245
|
+
const existingPayload = await this.adapter.get(tagKey);
|
|
246
|
+
if (!existingPayload) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const keys = this.deserializeTagSet(existingPayload);
|
|
251
|
+
const updated = keys.filter(key => key !== namespacedKey);
|
|
252
|
+
if (updated.length === 0) {
|
|
253
|
+
await this.adapter.delete(tagKey);
|
|
254
|
+
} else if (updated.length !== keys.length) {
|
|
255
|
+
await this.adapter.set(tagKey, JSON.stringify(updated));
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private normalizeKeyPart(part: CacheKeyPart): string[] {
|
|
262
|
+
if (part === undefined || part === null || part === '') {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (Array.isArray(part)) {
|
|
267
|
+
return part.flatMap(item => this.normalizeKeyPart(item as CacheKeyPart));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (typeof part === 'object') {
|
|
271
|
+
return [this.normalizeObject(part as Record<string, unknown>)];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return [String(part)];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private normalizeObject(input: Record<string, unknown>): string {
|
|
278
|
+
const sortedKeys = Object.keys(input).sort();
|
|
279
|
+
const normalized: Record<string, unknown> = {};
|
|
280
|
+
|
|
281
|
+
for (const key of sortedKeys) {
|
|
282
|
+
const value = (input as Record<string, unknown>)[key];
|
|
283
|
+
if (value === undefined) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
288
|
+
normalized[key] = JSON.parse(this.normalizeObject(value as Record<string, unknown>));
|
|
289
|
+
} else if (Array.isArray(value)) {
|
|
290
|
+
normalized[key] = value.map(item =>
|
|
291
|
+
typeof item === 'object' && item !== null
|
|
292
|
+
? JSON.parse(this.normalizeObject(item as Record<string, unknown>))
|
|
293
|
+
: item
|
|
294
|
+
);
|
|
295
|
+
} else {
|
|
296
|
+
normalized[key] = value;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return JSON.stringify(normalized);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory cache adapter primarily suited for development and testing.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CacheAdapter } from './types';
|
|
6
|
+
|
|
7
|
+
interface StoredValue {
|
|
8
|
+
value: string;
|
|
9
|
+
expiresAt?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class InMemoryCacheAdapter implements CacheAdapter {
|
|
13
|
+
private store = new Map<string, StoredValue>();
|
|
14
|
+
|
|
15
|
+
async get(key: string): Promise<string | undefined> {
|
|
16
|
+
const entry = this.store.get(key);
|
|
17
|
+
if (!entry) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (entry.expiresAt && entry.expiresAt <= Date.now()) {
|
|
22
|
+
this.store.delete(key);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return entry.value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async set(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void> {
|
|
30
|
+
const expiresAt =
|
|
31
|
+
options?.ttlSeconds && options.ttlSeconds > 0
|
|
32
|
+
? Date.now() + options.ttlSeconds * 1000
|
|
33
|
+
: undefined;
|
|
34
|
+
|
|
35
|
+
this.store.set(key, { value, expiresAt });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async delete(key: string): Promise<void> {
|
|
39
|
+
this.store.delete(key);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async deleteMany(keys: string[]): Promise<void> {
|
|
43
|
+
keys.forEach(key => this.store.delete(key));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async clear(): Promise<void> {
|
|
47
|
+
this.store.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-op cache adapter that disables caching while preserving the cache contract.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CacheAdapter } from './types';
|
|
6
|
+
|
|
7
|
+
export class NoopCacheAdapter implements CacheAdapter {
|
|
8
|
+
async get(): Promise<undefined> {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async set(): Promise<void> {
|
|
13
|
+
// Intentionally blank
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async delete(): Promise<void> {
|
|
17
|
+
// Intentionally blank
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async deleteMany(): Promise<void> {
|
|
21
|
+
// Intentionally blank
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async clear(): Promise<void> {
|
|
25
|
+
// Intentionally blank
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache-related types for the PerspectAPI SDK.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type CacheKeyPart =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| undefined
|
|
11
|
+
| Record<string, unknown>
|
|
12
|
+
| Array<Record<string, unknown> | string | number | boolean | null | undefined>;
|
|
13
|
+
|
|
14
|
+
export interface CacheSetOptions {
|
|
15
|
+
ttlSeconds?: number;
|
|
16
|
+
tags?: string[];
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CachePolicy extends CacheSetOptions {
|
|
21
|
+
skipCache?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CacheEntry<T = unknown> {
|
|
25
|
+
value: T;
|
|
26
|
+
expiresAt?: number;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CacheAdapter {
|
|
32
|
+
/**
|
|
33
|
+
* Retrieve a raw cache payload for the given key. Implementations should return
|
|
34
|
+
* undefined (or null) when the key does not exist or has expired.
|
|
35
|
+
*/
|
|
36
|
+
get(key: string): Promise<string | undefined | null>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Store a raw payload for the given key. Implementations MAY honour ttlSeconds
|
|
40
|
+
* natively; the cache manager will also persist expiry timestamps in the payload
|
|
41
|
+
* for adapters that do not have native TTL support.
|
|
42
|
+
*/
|
|
43
|
+
set(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remove a cached entry.
|
|
47
|
+
*/
|
|
48
|
+
delete(key: string): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Optional bulk delete implementation.
|
|
52
|
+
*/
|
|
53
|
+
deleteMany?(keys: string[]): Promise<void>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Optional clear method to wipe all entries for adapters that manage isolated namespaces.
|
|
57
|
+
*/
|
|
58
|
+
clear?(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CacheManagerOptions {
|
|
62
|
+
defaultTtlSeconds?: number;
|
|
63
|
+
keyPrefix?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CacheConfig extends CacheManagerOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Explicit flag to disable caching. Defaults to false when a cache configuration
|
|
69
|
+
* is supplied, so caching is considered enabled unless explicitly disabled.
|
|
70
|
+
*/
|
|
71
|
+
enabled?: boolean;
|
|
72
|
+
adapter?: CacheAdapter;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CacheInvalidateOptions {
|
|
76
|
+
keys?: string[];
|
|
77
|
+
tags?: string[];
|
|
78
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { BaseClient } from './base-client';
|
|
6
|
+
import type { CacheManager } from '../cache/cache-manager';
|
|
6
7
|
import type {
|
|
7
8
|
ApiKey,
|
|
8
9
|
CreateApiKeyRequest,
|
|
@@ -12,8 +13,8 @@ import type {
|
|
|
12
13
|
} from '../types';
|
|
13
14
|
|
|
14
15
|
export class ApiKeysClient extends BaseClient {
|
|
15
|
-
constructor(http: any) {
|
|
16
|
-
super(http, '/api/v1');
|
|
16
|
+
constructor(http: any, cache?: CacheManager) {
|
|
17
|
+
super(http, '/api/v1', cache);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { BaseClient } from './base-client';
|
|
6
|
+
import type { CacheManager } from '../cache/cache-manager';
|
|
6
7
|
import type {
|
|
7
8
|
SignUpRequest,
|
|
8
9
|
SignInRequest,
|
|
@@ -12,8 +13,8 @@ import type {
|
|
|
12
13
|
} from '../types';
|
|
13
14
|
|
|
14
15
|
export class AuthClient extends BaseClient {
|
|
15
|
-
constructor(http: any) {
|
|
16
|
-
super(http, '/api/v1');
|
|
16
|
+
constructor(http: any, cache?: CacheManager) {
|
|
17
|
+
super(http, '/api/v1', cache);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { HttpClient } from '../utils/http-client';
|
|
6
6
|
import type { ApiResponse } from '../types';
|
|
7
|
+
import type { CacheManager } from '../cache/cache-manager';
|
|
8
|
+
import type { CachePolicy, CacheInvalidateOptions } from '../cache/types';
|
|
7
9
|
|
|
8
10
|
export abstract class BaseClient {
|
|
9
11
|
protected http: HttpClient;
|
|
10
12
|
protected basePath: string;
|
|
13
|
+
protected cache?: CacheManager;
|
|
11
14
|
|
|
12
|
-
constructor(http: HttpClient, basePath: string) {
|
|
15
|
+
constructor(http: HttpClient, basePath: string, cache?: CacheManager) {
|
|
13
16
|
this.http = http;
|
|
14
17
|
this.basePath = basePath;
|
|
18
|
+
this.cache = cache && cache.isEnabled() ? cache : undefined;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -100,4 +104,87 @@ export abstract class BaseClient {
|
|
|
100
104
|
protected async delete<T = any>(endpoint: string, csrfToken?: string): Promise<ApiResponse<T>> {
|
|
101
105
|
return this.http.delete<T>(this.buildPath(endpoint), { csrfToken });
|
|
102
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetch a GET endpoint with optional caching support.
|
|
110
|
+
*/
|
|
111
|
+
protected async fetchWithCache<T>(
|
|
112
|
+
endpoint: string,
|
|
113
|
+
params: Record<string, any> | undefined,
|
|
114
|
+
tags: string[],
|
|
115
|
+
policy: CachePolicy | undefined,
|
|
116
|
+
fetcher: () => Promise<T>
|
|
117
|
+
): Promise<T> {
|
|
118
|
+
if (!this.cache) {
|
|
119
|
+
return fetcher();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cacheKey = this.buildCacheKey(endpoint, params);
|
|
123
|
+
const combinedPolicy: CachePolicy | undefined = policy
|
|
124
|
+
? { ...policy, tags: this.mergeTags(tags, policy.tags) }
|
|
125
|
+
: { tags };
|
|
126
|
+
|
|
127
|
+
return this.cache.getOrSet<T>(cacheKey, fetcher, combinedPolicy);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Invalidate cache entries by keys or tags.
|
|
132
|
+
*/
|
|
133
|
+
protected async invalidateCache(options: CacheInvalidateOptions): Promise<void> {
|
|
134
|
+
if (!this.cache) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await this.cache.invalidate(options);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a consistent cache key for an endpoint + params combination.
|
|
142
|
+
*/
|
|
143
|
+
protected buildCacheKey(endpoint: string, params?: Record<string, any>): string {
|
|
144
|
+
const sanitizedEndpoint = endpoint.replace(/^\//, '');
|
|
145
|
+
const baseSegment = this.basePath.replace(/^\//, '');
|
|
146
|
+
|
|
147
|
+
const parts: Array<string | Record<string, unknown>> = [baseSegment, sanitizedEndpoint];
|
|
148
|
+
|
|
149
|
+
if (params && Object.keys(params).length > 0) {
|
|
150
|
+
parts.push(this.sortObject(params));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.cache) {
|
|
154
|
+
return this.cache.buildKey(parts);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return parts
|
|
158
|
+
.map(part => (typeof part === 'string' ? part : JSON.stringify(part)))
|
|
159
|
+
.join(':');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private mergeTags(defaultTags: string[], overrideTags?: string[]): string[] {
|
|
163
|
+
const combined = new Set<string>();
|
|
164
|
+
defaultTags.forEach(tag => combined.add(tag));
|
|
165
|
+
overrideTags?.forEach(tag => combined.add(tag));
|
|
166
|
+
return Array.from(combined.values());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private sortObject(input: Record<string, any>): Record<string, any> {
|
|
170
|
+
const sortedKeys = Object.keys(input).sort();
|
|
171
|
+
const result: Record<string, any> = {};
|
|
172
|
+
|
|
173
|
+
for (const key of sortedKeys) {
|
|
174
|
+
const value = input[key];
|
|
175
|
+
if (value === undefined) continue;
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(value)) {
|
|
178
|
+
result[key] = value.map(item =>
|
|
179
|
+
typeof item === 'object' && item !== null ? this.sortObject(item as Record<string, any>) : item
|
|
180
|
+
);
|
|
181
|
+
} else if (value && typeof value === 'object') {
|
|
182
|
+
result[key] = this.sortObject(value as Record<string, any>);
|
|
183
|
+
} else {
|
|
184
|
+
result[key] = value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
103
190
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { BaseClient } from './base-client';
|
|
6
|
+
import type { CacheManager } from '../cache/cache-manager';
|
|
7
|
+
import type { CachePolicy } from '../cache/types';
|
|
6
8
|
import type {
|
|
7
9
|
Category,
|
|
8
10
|
CreateCategoryRequest,
|
|
@@ -11,8 +13,8 @@ import type {
|
|
|
11
13
|
} from '../types';
|
|
12
14
|
|
|
13
15
|
export class CategoriesClient extends BaseClient {
|
|
14
|
-
constructor(http: any) {
|
|
15
|
-
super(http, '/api/v1');
|
|
16
|
+
constructor(http: any, cache?: CacheManager) {
|
|
17
|
+
super(http, '/api/v1', cache);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -44,14 +46,25 @@ export class CategoriesClient extends BaseClient {
|
|
|
44
46
|
/**
|
|
45
47
|
* Get product category by slug (for products)
|
|
46
48
|
*/
|
|
47
|
-
async getProductCategoryBySlug(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
async getProductCategoryBySlug(
|
|
50
|
+
siteName: string,
|
|
51
|
+
slug: string,
|
|
52
|
+
cachePolicy?: CachePolicy
|
|
53
|
+
): Promise<ApiResponse<Category>> {
|
|
54
|
+
const endpoint = this.siteScopedEndpoint(
|
|
55
|
+
siteName,
|
|
56
|
+
`/product_category/slug/${encodeURIComponent(slug)}`,
|
|
57
|
+
{ includeSitesSegment: false }
|
|
58
|
+
);
|
|
59
|
+
const path = this.buildPath(endpoint);
|
|
60
|
+
|
|
61
|
+
return this.fetchWithCache<ApiResponse<Category>>(
|
|
62
|
+
endpoint,
|
|
63
|
+
undefined,
|
|
64
|
+
this.buildCategoryTags(siteName, slug),
|
|
65
|
+
cachePolicy,
|
|
66
|
+
() => this.http.get<Category>(path)
|
|
67
|
+
);
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
/**
|
|
@@ -152,4 +165,15 @@ export class CategoriesClient extends BaseClient {
|
|
|
152
165
|
}): Promise<ApiResponse<Category[]>> {
|
|
153
166
|
return this.http.get(`/categories/search`, { q: query, ...params });
|
|
154
167
|
}
|
|
168
|
+
|
|
169
|
+
private buildCategoryTags(siteName: string, slug: string): string[] {
|
|
170
|
+
const tags = new Set<string>(['categories']);
|
|
171
|
+
if (siteName) {
|
|
172
|
+
tags.add(`categories:site:${siteName}`);
|
|
173
|
+
}
|
|
174
|
+
if (slug) {
|
|
175
|
+
tags.add(`categories:product:${siteName}:${slug}`);
|
|
176
|
+
}
|
|
177
|
+
return Array.from(tags.values());
|
|
178
|
+
}
|
|
155
179
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { BaseClient } from './base-client';
|
|
6
|
+
import type { CacheManager } from '../cache/cache-manager';
|
|
6
7
|
import type {
|
|
7
8
|
CreateCheckoutSessionRequest,
|
|
8
9
|
CheckoutSession,
|
|
@@ -10,8 +11,8 @@ import type {
|
|
|
10
11
|
} from '../types';
|
|
11
12
|
|
|
12
13
|
export class CheckoutClient extends BaseClient {
|
|
13
|
-
constructor(http: any) {
|
|
14
|
-
super(http, '/api/v1');
|
|
14
|
+
constructor(http: any, cache?: CacheManager) {
|
|
15
|
+
super(http, '/api/v1', cache);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|