Add CachedApiClient decorator layered above ApiClient
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CachedApiClient } from "./cached-client.js";
|
||||
import type { ApiClient } from "./client.js";
|
||||
import { RequestScopedCache, ClientMemoryCache } from "./cache.js";
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: "fresh" })),
|
||||
post: vi.fn(async () => ({ data: "posted" })),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe("CachedApiClient", () => {
|
||||
it("delegates to the underlying client on cache miss", async () => {
|
||||
const client = mockClient();
|
||||
const cached = new CachedApiClient({ client });
|
||||
const result = await cached.get("/flights");
|
||||
expect(result).toEqual({ data: "fresh" });
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns cached value from request-scoped cache on hit", async () => {
|
||||
const client = mockClient();
|
||||
const requestScoped = new RequestScopedCache();
|
||||
const cached = new CachedApiClient({ client, requestScoped });
|
||||
|
||||
// First call populates the cache
|
||||
await cached.get("/flights");
|
||||
// Second call should hit the request-scoped cache
|
||||
await cached.get("/flights");
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns cached value from client-memory cache on hit", async () => {
|
||||
const client = mockClient();
|
||||
const clientMemory = new ClientMemoryCache<NonNullable<unknown>>({ max: 10, defaultTtlMs: 5000 });
|
||||
const cached = new CachedApiClient({ client, clientMemory });
|
||||
|
||||
await cached.get("/flights");
|
||||
await cached.get("/flights");
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cache key differs by query params", async () => {
|
||||
const client = mockClient();
|
||||
const clientMemory = new ClientMemoryCache<NonNullable<unknown>>({ max: 10, defaultTtlMs: 5000 });
|
||||
const cached = new CachedApiClient({ client, clientMemory });
|
||||
|
||||
await cached.get("/flights", { date: "2025-01-15" });
|
||||
await cached.get("/flights", { date: "2025-01-16" });
|
||||
|
||||
expect(client.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ApiClient } from "./client.js";
|
||||
import type { RequestScopedCache, ClientMemoryCache, ServerLruCache } from "./cache.js";
|
||||
|
||||
export interface CachedClientOptions {
|
||||
client: ApiClient;
|
||||
requestScoped?: RequestScopedCache | undefined;
|
||||
clientMemory?: ClientMemoryCache<NonNullable<unknown>> | undefined;
|
||||
serverLru?: ServerLruCache<NonNullable<unknown>> | undefined;
|
||||
ttlMs?: number | undefined;
|
||||
}
|
||||
|
||||
export class CachedApiClient {
|
||||
private readonly client: ApiClient;
|
||||
private readonly requestScoped: RequestScopedCache | undefined;
|
||||
private readonly clientMemory: ClientMemoryCache<NonNullable<unknown>> | undefined;
|
||||
private readonly serverLru: ServerLruCache<NonNullable<unknown>> | undefined;
|
||||
private readonly ttlMs: number | undefined;
|
||||
|
||||
constructor(options: CachedClientOptions) {
|
||||
this.client = options.client;
|
||||
this.requestScoped = options.requestScoped;
|
||||
this.clientMemory = options.clientMemory;
|
||||
this.serverLru = options.serverLru;
|
||||
this.ttlMs = options.ttlMs;
|
||||
}
|
||||
|
||||
async get<T>(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean>,
|
||||
): Promise<T> {
|
||||
const key = this.buildKey(path, query);
|
||||
|
||||
// (1) Check request-scoped cache (SSR dedup)
|
||||
if (this.requestScoped) {
|
||||
const cached = this.requestScoped.get<T>(key);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// (2) Check client-memory cache
|
||||
if (this.clientMemory) {
|
||||
const cached = this.clientMemory.get(key) as T | undefined;
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
|
||||
// (3) Check server LRU cache
|
||||
if (this.serverLru) {
|
||||
const cached = this.serverLru.get(key) as T | undefined;
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
|
||||
// Cache miss — fetch from upstream
|
||||
const promise = this.client.get<T>(path, query);
|
||||
|
||||
// Store the in-flight promise in request-scoped cache for dedup
|
||||
if (this.requestScoped) {
|
||||
this.requestScoped.set(key, promise);
|
||||
}
|
||||
|
||||
const result = await promise;
|
||||
|
||||
// Populate longer-lived caches with the resolved value
|
||||
if (this.clientMemory) {
|
||||
this.clientMemory.set(key, result as NonNullable<unknown>, this.ttlMs);
|
||||
}
|
||||
if (this.serverLru) {
|
||||
this.serverLru.set(key, result as NonNullable<unknown>, this.ttlMs);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildKey(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean>,
|
||||
): string {
|
||||
const q = query
|
||||
? Object.keys(query)
|
||||
.sort()
|
||||
.map((k) => `${k}=${String(query[k])}`)
|
||||
.join("&")
|
||||
: "";
|
||||
return `${path}?${q}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user