-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: price in memory caching #31
Changes from 7 commits
b290492
48b22a0
f307c36
ee9118a
0dd3f0a
8e0015d
74c7ec2
ce0ad2f
545c857
e01801c
bbb3668
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
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"; | ||
import { AxiosInstance } from "axios"; | ||
import MockAdapter from "axios-mock-adapter"; | ||
import { WINSTON_MODULE_PROVIDER } from "nest-winston"; | ||
|
||
|
@@ -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<Cache>({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pretty neat solution the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. its from some guys that made utilities and helpers for NestJs actually jajaja |
||
store: createMock<Cache["store"]>({ | ||
mget: jest.fn(), | ||
mset: jest.fn(), | ||
}), | ||
}), | ||
}, | ||
], | ||
}).compile(); | ||
|
||
service = module.get<CoingeckoService>(CoingeckoService); | ||
axios = service["axios"]; | ||
mockAxios = new MockAdapter(axios); | ||
cache = module.get<Cache>(CACHE_MANAGER); | ||
}); | ||
|
||
afterEach(() => { | ||
|
@@ -67,14 +80,15 @@ describe("CoingeckoService", () => { | |
}); | ||
|
||
describe("getTokenPrices", () => { | ||
it("return token prices", async () => { | ||
it("all token prices are fetched from Coingecko", async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what ? 🤣
0xnigir1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const tokenIds = ["token1", "token2"]; | ||
const currency = "usd"; | ||
const expectedResponse: TokenPrices = { | ||
token1: { usd: 1.23 }, | ||
token2: { usd: 4.56 }, | ||
}; | ||
|
||
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); | ||
jest.spyOn(axios, "get").mockResolvedValueOnce({ | ||
data: expectedResponse, | ||
}); | ||
|
@@ -92,12 +106,18 @@ 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 () => { | ||
const tokenIds = ["token1", "token2"]; | ||
const currency = "usd"; | ||
|
||
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); | ||
mockAxios.onGet().replyOnce(503, { | ||
data: {}, | ||
status: 503, | ||
|
@@ -113,6 +133,7 @@ describe("CoingeckoService", () => { | |
const tokenIds = ["token1", "token2"]; | ||
const currency = "usd"; | ||
|
||
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); | ||
mockAxios.onGet().replyOnce(429, { | ||
data: {}, | ||
status: 429, | ||
|
@@ -128,10 +149,7 @@ describe("CoingeckoService", () => { | |
const tokenIds = ["invalidTokenId", "token2"]; | ||
const currency = "usd"; | ||
|
||
jest.spyOn(axios, "get").mockRejectedValueOnce( | ||
new AxiosError("Invalid token ID", "400"), | ||
); | ||
|
||
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]); | ||
mockAxios.onGet().replyOnce(400, { | ||
data: { | ||
message: "Invalid token ID", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Record<string, number>> { | ||
const { currency } = config; | ||
|
||
const cacheKeys = tokenIds.map((tokenId) => this.formatTokenCacheKey(tokenId, currency)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is it posible to have sth like this on the Cache ? { < currency > : { < tokenId > : < amount > } } iam not a huge fan of formatting keys There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @0xyaco would love to hear your opinion here ser There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. storing an object/map as value? a simpler solution is an only USD service There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea, i think we are not gona be using other currency as baseCurrency , lets keep it like it is for now |
||
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<string, number>, | ||
); | ||
|
||
const missingTokenIds = tokenIds.filter((_, index) => !cachedTokenPrices[index]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this filter could be done inside the reduce i think |
||
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<string, number>, 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<Record<string, number>> { | ||
if (tokenIds.length === 0) { | ||
return {}; | ||
} | ||
|
||
return this.axios | ||
.get<TokenPrices>("simple/price", { | ||
params: { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const TOKEN_CACHE_TTL_IN_SEC = 60; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sweet