diff --git a/src/shared/api/cached-client.test.ts b/src/shared/api/cached-client.test.ts new file mode 100644 index 00000000..06051308 --- /dev/null +++ b/src/shared/api/cached-client.test.ts @@ -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>({ 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>({ 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); + }); +}); diff --git a/src/shared/api/cached-client.ts b/src/shared/api/cached-client.ts new file mode 100644 index 00000000..4a441ee7 --- /dev/null +++ b/src/shared/api/cached-client.ts @@ -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> | undefined; + serverLru?: ServerLruCache> | undefined; + ttlMs?: number | undefined; +} + +export class CachedApiClient { + private readonly client: ApiClient; + private readonly requestScoped: RequestScopedCache | undefined; + private readonly clientMemory: ClientMemoryCache> | undefined; + private readonly serverLru: ServerLruCache> | 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( + path: string, + query?: Record, + ): Promise { + const key = this.buildKey(path, query); + + // (1) Check request-scoped cache (SSR dedup) + if (this.requestScoped) { + const cached = this.requestScoped.get(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(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, this.ttlMs); + } + if (this.serverLru) { + this.serverLru.set(key, result as NonNullable, this.ttlMs); + } + + return result; + } + + private buildKey( + path: string, + query?: Record, + ): string { + const q = query + ? Object.keys(query) + .sort() + .map((k) => `${k}=${String(query[k])}`) + .join("&") + : ""; + return `${path}?${q}`; + } +}