diff --git a/libs/pricing/src/pricing.module.ts b/libs/pricing/src/pricing.module.ts index 6a5b670..1154094 100644 --- a/libs/pricing/src/pricing.module.ts +++ b/libs/pricing/src/pricing.module.ts @@ -1,3 +1,4 @@ +import { CacheModule } from "@nestjs/cache-manager"; import { Module } from "@nestjs/common"; import { LoggerModule } from "@zkchainhub/shared"; @@ -5,7 +6,13 @@ import { LoggerModule } from "@zkchainhub/shared"; import { CoingeckoService } from "./services"; @Module({ - imports: [LoggerModule], + imports: [ + LoggerModule, + CacheModule.register({ + store: "memory", + ttl: 60, // seconds + }), + ], providers: [CoingeckoService], exports: [CoingeckoService], }) diff --git a/libs/pricing/src/services/coingecko.service.spec.ts b/libs/pricing/src/services/coingecko.service.spec.ts index 55da593..f230a22 100644 --- a/libs/pricing/src/services/coingecko.service.spec.ts +++ b/libs/pricing/src/services/coingecko.service.spec.ts @@ -1,3 +1,5 @@ +import { createMock } from "@golevelup/ts-jest"; +import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; import { Logger } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import { AxiosError, AxiosInstance } from "axios"; @@ -20,6 +22,7 @@ describe("CoingeckoService", () => { let service: CoingeckoService; let axios: AxiosInstance; let mockAxios: MockAdapter; + let cache: Cache; const apiKey = "COINGECKO_API_KEY"; const apiBaseUrl = "https://api.coingecko.com/api/v3/"; @@ -29,21 +32,31 @@ describe("CoingeckoService", () => { CoingeckoService, { provide: CoingeckoService, - useFactory: (logger: Logger) => { - return new CoingeckoService(apiKey, apiBaseUrl, logger); + useFactory: (logger: Logger, cache: Cache) => { + return new CoingeckoService(apiKey, apiBaseUrl, logger, cache); }, - inject: [WINSTON_MODULE_PROVIDER], + inject: [WINSTON_MODULE_PROVIDER, CACHE_MANAGER], }, { provide: WINSTON_MODULE_PROVIDER, useValue: mockLogger, }, + { + provide: CACHE_MANAGER, + useValue: createMock({ + store: createMock({ + mget: jest.fn(), + mset: jest.fn(), + }), + }), + }, ], }).compile(); service = module.get(CoingeckoService); axios = service["axios"]; mockAxios = new MockAdapter(axios); + cache = module.get(CACHE_MANAGER); }); afterEach(() => { @@ -67,7 +80,7 @@ describe("CoingeckoService", () => { }); describe("getTokenPrices", () => { - it("return token prices", async () => { + it("all token prices are fetched from Coingecko", async () => { const tokenIds = ["token1", "token2"]; const currency = "usd"; const expectedResponse: TokenPrices = { @@ -75,6 +88,7 @@ describe("CoingeckoService", () => { token2: { usd: 4.56 }, }; + jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); jest.spyOn(axios, "get").mockResolvedValueOnce({ data: expectedResponse, }); @@ -92,6 +106,11 @@ describe("CoingeckoService", () => { precision: service["DECIMALS_PRECISION"].toString(), }, }); + expect(cache.store.mget).toHaveBeenCalledWith("token1.usd", "token2.usd"); + expect(cache.store.mset).toHaveBeenCalledWith([ + ["token1.usd", 1.23], + ["token2.usd", 4.56], + ]); }); it("throw ApiNotAvailable when Coingecko returns a 500 family exception", async () => { @@ -103,6 +122,7 @@ describe("CoingeckoService", () => { status: 503, statusText: "Service not available", }); + jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow( new ApiNotAvailable("Coingecko"), @@ -118,6 +138,7 @@ describe("CoingeckoService", () => { status: 429, statusText: "Too Many Requests", }); + jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow( new RateLimitExceeded(), @@ -128,6 +149,7 @@ describe("CoingeckoService", () => { const tokenIds = ["invalidTokenId", "token2"]; const currency = "usd"; + jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); jest.spyOn(axios, "get").mockRejectedValueOnce( new AxiosError("Invalid token ID", "400"), ); diff --git a/libs/pricing/src/services/coingecko.service.ts b/libs/pricing/src/services/coingecko.service.ts index 567b5bf..0a8ab71 100644 --- a/libs/pricing/src/services/coingecko.service.ts +++ b/libs/pricing/src/services/coingecko.service.ts @@ -1,3 +1,4 @@ +import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, LoggerService } from "@nestjs/common"; import axios, { AxiosInstance, isAxiosError } from "axios"; import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston"; @@ -24,6 +25,7 @@ export class CoingeckoService implements IPricingService { private readonly apiKey: string, private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/", @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, ) { this.axios = axios.create({ baseURL: apiBaseUrl, @@ -50,6 +52,62 @@ export class CoingeckoService implements IPricingService { ): Promise> { const { currency } = config; + const cacheKeys = tokenIds.map((tokenId) => this.formatTokenCacheKey(tokenId, currency)); + const cachedTokenPrices = await this.getTokenPricesFromCache(cacheKeys); + const cachedMap = cachedTokenPrices.reduce( + (result, price, index) => { + if (price !== null) result[tokenIds.at(index) as string] = price; + return result; + }, + {} as Record, + ); + + const missingTokenIds = tokenIds.filter((_, index) => !cachedTokenPrices[index]); + const missingTokenPrices = await this.fetchTokenPrices(missingTokenIds, currency); + + await this.saveTokenPricesToCache(missingTokenPrices, currency); + + return { ...cachedMap, ...missingTokenPrices }; + } + + private formatTokenCacheKey(tokenId: string, currency: string) { + return `${tokenId}.${currency}`; + } + + /** + * Retrieves multiple token prices from the cache at once. + * @param keys - An array of cache keys. + * @returns A promise that resolves to an array of token prices (number or null). + */ + private async getTokenPricesFromCache(keys: string[]): Promise<(number | null)[]> { + return this.cacheManager.store.mget(...keys) as Promise<(number | null)[]>; + } + + /** + * Saves multiple token prices to the cache at once. + * + * @param prices - The token prices to be saved. + * @param currency - The currency in which the prices are denominated. + */ + private async saveTokenPricesToCache(prices: Record, currency: string) { + if (Object.keys(prices).length === 0) return; + + this.cacheManager.store.mset( + Object.entries(prices).map(([key, value]) => [ + this.formatTokenCacheKey(key, currency), + value, + ]), + ); + } + + private async fetchTokenPrices( + tokenIds: string[], + currency: string, + ): Promise> { + if (tokenIds.length === 0) { + return {}; + } + return this.axios .get("simple/price", { params: { diff --git a/package.json b/package.json index c72a7c8..ad48529 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@nestjs/axios": "3.0.2", + "@nestjs/cache-manager": "2.2.2", "@nestjs/common": "10.0.0", "@nestjs/core": "10.0.0", "@nestjs/platform-express": "10.0.0", @@ -32,6 +33,7 @@ "axios": "1.7.2", "axios-mock-adapter": "1.22.0", "nest-winston": "1.9.7", + "cache-manager": "5.7.4", "reflect-metadata": "0.1.13", "rxjs": "7.8.1", "viem": "2.17.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56ceb91..447a081 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@nestjs/axios': specifier: 3.0.2 version: 3.0.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1) + '@nestjs/cache-manager': + specifier: 2.2.2 + version: 2.2.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1))(cache-manager@5.7.4)(rxjs@7.8.1) '@nestjs/common': specifier: 10.0.0 version: 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) @@ -32,6 +35,9 @@ importers: axios-mock-adapter: specifier: 1.22.0 version: 1.22.0(axios@1.7.2) + cache-manager: + specifier: 5.7.4 + version: 5.7.4 nest-winston: specifier: 1.9.7 version: 1.9.7(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(winston@3.13.1) @@ -566,6 +572,14 @@ packages: axios: ^1.3.1 rxjs: ^6.0.0 || ^7.0.0 + '@nestjs/cache-manager@2.2.2': + resolution: {integrity: sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + cache-manager: <=5 + rxjs: ^7.0.0 + '@nestjs/cli@10.0.0': resolution: {integrity: sha512-14pju3ejAAUpFe1iK99v/b7Bw96phBMV58GXTSm3TcdgaI4O7UTLXTbMiUNyU+LGr/1CPIfThcWqFyKhDIC9VQ==} engines: {node: '>= 16'} @@ -1234,6 +1248,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cache-manager@5.7.4: + resolution: {integrity: sha512-7B29xK1D8hOVdrP0SAy2DGJ/QZxy2TqxS8s2drlLGYI/xOTSJmXfatks7aKKNHvXN6SnKnPtYCi0T82lslB3Fw==} + engines: {node: '>= 18'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -2377,6 +2395,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -2754,6 +2775,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + promise-coalesce@1.1.2: + resolution: {integrity: sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==} + engines: {node: '>=16'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4065,6 +4090,13 @@ snapshots: axios: 1.7.2 rxjs: 7.8.1 + '@nestjs/cache-manager@2.2.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1))(cache-manager@5.7.4)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) + '@nestjs/core': 10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1) + cache-manager: 5.7.4 + rxjs: 7.8.1 + '@nestjs/cli@10.0.0(@swc/core@1.6.13)': dependencies: '@angular-devkit/core': 16.1.0(chokidar@3.5.3) @@ -4834,6 +4866,13 @@ snapshots: bytes@3.1.2: {} + cache-manager@5.7.4: + dependencies: + eventemitter3: 5.0.1 + lodash.clonedeep: 4.5.0 + lru-cache: 10.4.0 + promise-coalesce: 1.1.2 + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -6205,6 +6244,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.isplainobject@4.0.6: {} lodash.kebabcase@4.1.1: {} @@ -6524,6 +6565,8 @@ snapshots: process-nextick-args@2.0.1: {} + promise-coalesce@1.1.2: {} + prompts@2.4.2: dependencies: kleur: 3.0.3