Skip to content
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

Merged
merged 11 commits into from
Aug 1, 2024
3 changes: 0 additions & 3 deletions libs/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ export interface IPricingService {
/**
* Retrieves the prices of the specified tokens.
* @param tokenIds - An array of token IDs.
* @param [config] - Optional configuration object.
* @param config.currency - The currency in which the prices should be returned.
* @returns A promise that resolves to a record containing the token IDs as keys and their corresponding prices as values.
*/
getTokenPrices<TokenId extends string = string>(
tokenIds: TokenId[],
config?: { currency: string },
): Promise<Record<string, number>>;
}
10 changes: 9 additions & 1 deletion libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";

import { LoggerModule } from "@zkchainhub/shared";
import { TOKEN_CACHE_TTL_IN_SEC } from "@zkchainhub/shared/constants/";

import { CoingeckoService } from "./services";

@Module({
imports: [LoggerModule],
imports: [
LoggerModule,
CacheModule.register({
store: "memory",
ttl: TOKEN_CACHE_TTL_IN_SEC,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweet

}),
],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
57 changes: 35 additions & 22 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { TokenPrices } from "@zkchainhub/pricing/types/tokenPrice.type";
import { BASE_CURRENCY } from "@zkchainhub/shared";

import { CoingeckoService } from "./coingecko.service";
import { CoingeckoService, DECIMALS_PRECISION } from "./coingecko.service";

export const mockLogger: Partial<Logger> = {
log: jest.fn(),
Expand All @@ -20,6 +23,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/";

Expand All @@ -29,21 +33,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>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty neat solution the createMock utility function, didn't know it existed, nice

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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(() => {
Expand All @@ -67,71 +81,70 @@ describe("CoingeckoService", () => {
});

describe("getTokenPrices", () => {
it("return token prices", async () => {
it("fetches all token prices from Coingecko", async () => {
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,
});

const result = await service.getTokenPrices(tokenIds, { currency });
const result = await service.getTokenPrices(tokenIds);

expect(result).toEqual({
token1: 1.23,
token2: 4.56,
});
expect(axios.get).toHaveBeenCalledWith(`simple/price`, {
params: {
vs_currencies: currency,
vs_currencies: BASE_CURRENCY,
ids: tokenIds.join(","),
precision: service["DECIMALS_PRECISION"].toString(),
precision: DECIMALS_PRECISION.toString(),
},
});
expect(cache.store.mget).toHaveBeenCalledWith("token1", "token2");
expect(cache.store.mset).toHaveBeenCalledWith([
["token1", 1.23],
["token2", 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,
statusText: "Service not available",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow(
new ApiNotAvailable("Coingecko"),
);
});

it("throw RateLimitExceeded when Coingecko returns 429 exception", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(429, {
data: {},
status: 429,
statusText: "Too Many Requests",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new RateLimitExceeded(),
);
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow(new RateLimitExceeded());
});

it("throw an HttpException with the error message when an error occurs", async () => {
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",
Expand All @@ -140,7 +153,7 @@ describe("CoingeckoService", () => {
statusText: "Bad Request",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow();
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow();
});
});
});
73 changes: 62 additions & 11 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { IPricingService } from "@zkchainhub/pricing/interfaces";
import { TokenPrices } from "@zkchainhub/pricing/types/tokenPrice.type";
import { BASE_CURRENCY } from "@zkchainhub/shared";

export const AUTH_HEADER = "x-cg-pro-api-key";
export const DECIMALS_PRECISION = 3;

/**
* Service for fetching token prices from Coingecko API.
* Prices are always denominated in USD.
*/
@Injectable()
export class CoingeckoService implements IPricingService {
private readonly AUTH_HEADER = "x-cg-pro-api-key";
private readonly DECIMALS_PRECISION = 3;
private readonly axios: AxiosInstance;

/**
Expand All @@ -24,12 +28,13 @@ 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,
headers: {
common: {
[this.AUTH_HEADER]: apiKey,
[AUTH_HEADER]: apiKey,
Accept: "application/json",
},
},
Expand All @@ -42,20 +47,66 @@ export class CoingeckoService implements IPricingService {

/**
* @param tokenIds - An array of Coingecko Tokens IDs.
* @param config.currency - The currency in which the prices should be returned (default: "usd").
* @returns A promise that resolves to a record of token prices in USD.
*/
async getTokenPrices(
tokenIds: string[],
config: { currency: string } = { currency: "usd" },
): Promise<Record<string, number>> {
const { currency } = config;
async getTokenPrices(tokenIds: string[]): Promise<Record<string, number>> {
const cachedTokenPrices = await this.getTokenPricesFromCache(tokenIds);
const missingTokenIds: string[] = [];
const cachedMap = cachedTokenPrices.reduce(
(result, price, index) => {
if (price !== null) result[tokenIds.at(index) as string] = price;
else missingTokenIds.push(tokenIds.at(index) as string);

return result;
},
{} as Record<string, number>,
);

const missingTokenPrices = await this.fetchTokenPrices(missingTokenIds);

await this.saveTokenPricesToCache(missingTokenPrices);

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>) {
if (Object.keys(prices).length === 0) return;

this.cacheManager.store.mset(
Object.entries(prices).map(([tokenId, price]) => [tokenId, price]),
);
}

private async fetchTokenPrices(tokenIds: string[]): Promise<Record<string, number>> {
if (tokenIds.length === 0) {
return {};
}

return this.axios
.get<TokenPrices>("simple/price", {
params: {
vs_currencies: currency,
vs_currencies: BASE_CURRENCY,
ids: tokenIds.join(","),
precision: this.DECIMALS_PRECISION.toString(),
precision: DECIMALS_PRECISION.toString(),
},
})
.then((response) => {
Expand Down
Empty file removed libs/shared/src/constants.ts
Empty file.
2 changes: 2 additions & 0 deletions libs/shared/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const TOKEN_CACHE_TTL_IN_SEC = 60;
export const BASE_CURRENCY = "usd";
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading