Skip to content

Commit

Permalink
Feat: pricing service impl (#30)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes ZKS-64

## Description

Add method to fetch multiple token prices from Coingecko API
  • Loading branch information
0xnigir1 authored Jul 30, 2024
1 parent 4e255c9 commit b3a0e96
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 9 deletions.
7 changes: 7 additions & 0 deletions libs/pricing/src/exceptions/apiNotAvailable.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpException, HttpStatus } from "@nestjs/common";

export class ApiNotAvailable extends HttpException {
constructor(apiName: string) {
super(`The ${apiName} API is not available.`, HttpStatus.SERVICE_UNAVAILABLE);
}
}
2 changes: 2 additions & 0 deletions libs/pricing/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./apiNotAvailable.exception";
export * from "./rateLimitExceeded.exception";
7 changes: 7 additions & 0 deletions libs/pricing/src/exceptions/rateLimitExceeded.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpException, HttpStatus } from "@nestjs/common";

export class RateLimitExceeded extends HttpException {
constructor() {
super("Rate limit exceeded.", HttpStatus.TOO_MANY_REQUESTS);
}
}
3 changes: 3 additions & 0 deletions libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from "@nestjs/common";

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

import { CoingeckoService } from "./services";

@Module({
imports: [LoggerModule],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
126 changes: 122 additions & 4 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,146 @@
import { Logger } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AxiosError, 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 { CoingeckoService } from "./coingecko.service";

export const mockLogger: Partial<Logger> = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};

describe("CoingeckoService", () => {
let service: CoingeckoService;
let axios: AxiosInstance;
let mockAxios: MockAdapter;
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoingeckoService,
{
provide: CoingeckoService,
useFactory: () => {
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";
return new CoingeckoService(apiKey, apiBaseUrl);
useFactory: (logger: Logger) => {
return new CoingeckoService(apiKey, apiBaseUrl, logger);
},
inject: [WINSTON_MODULE_PROVIDER],
},
{
provide: WINSTON_MODULE_PROVIDER,
useValue: mockLogger,
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
axios = service["axios"];
mockAxios = new MockAdapter(axios);
});

afterEach(() => {
jest.clearAllMocks();
mockAxios.reset();
});

it("should be defined", () => {
expect(service).toBeDefined();
});

it("should have an axios instance", () => {
expect(axios).toBeDefined();
expect(axios.defaults.baseURL).toBe(apiBaseUrl);
expect(axios.defaults.headers.common).toEqual(
expect.objectContaining({
"x-cg-pro-api-key": apiKey,
Accept: "application/json",
}),
);
});

describe("getTokenPrices", () => {
it("return token prices", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";
const expectedResponse: TokenPrices = {
token1: { usd: 1.23 },
token2: { usd: 4.56 },
};

jest.spyOn(axios, "get").mockResolvedValueOnce({
data: expectedResponse,
});

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

expect(result).toEqual({
token1: 1.23,
token2: 4.56,
});
expect(axios.get).toHaveBeenCalledWith(`simple/price`, {
params: {
vs_currencies: currency,
ids: tokenIds.join(","),
precision: service["DECIMALS_PRECISION"].toString(),
},
});
});

it("throw ApiNotAvailable when Coingecko returns a 500 family exception", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

mockAxios.onGet().replyOnce(503, {
data: {},
status: 503,
statusText: "Service not available",
});

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

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

mockAxios.onGet().replyOnce(429, {
data: {},
status: 429,
statusText: "Too Many Requests",
});

await expect(service.getTokenPrices(tokenIds, { currency })).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"),
);

mockAxios.onGet().replyOnce(400, {
data: {
message: "Invalid token ID",
},
status: 400,
statusText: "Bad Request",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow();
});
});
});
90 changes: 85 additions & 5 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,98 @@
import { Injectable } from "@nestjs/common";
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";

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

/**
*
* @param apiKey * @param apiKey - Coingecko API key.
* @param apiBaseUrl - Base URL for Coingecko API. If you have a Pro account, you can use the Pro API URL.
*/
constructor(
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
) {}
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService,
) {
this.axios = axios.create({
baseURL: apiBaseUrl,
headers: {
common: {
[this.AUTH_HEADER]: apiKey,
Accept: "application/json",
},
},
});
this.axios.interceptors.response.use(
(response) => response,
(error: unknown) => this.handleError(error),
);
}

/**
* @param tokenIds - An array of Coingecko Tokens IDs.
* @param config.currency - The currency in which the prices should be returned (default: "usd").
*/
async getTokenPrices(
_tokenIds: string[],
_config: { currency: string } = { currency: "usd" },
tokenIds: string[],
config: { currency: string } = { currency: "usd" },
): Promise<Record<string, number>> {
throw new Error("Method not implemented.");
const { currency } = config;

return this.axios
.get<TokenPrices>("simple/price", {
params: {
vs_currencies: currency,
ids: tokenIds.join(","),
precision: this.DECIMALS_PRECISION.toString(),
},
})
.then((response) => {
const { data } = response;
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, value.usd]),
);
});
}

/**
* Handles errors that occur during API requests.
* @param error - The error object to handle.
* @throws {ApiNotAvailable} - If the error is a server-side error (status code >= 500).
* @throws {RateLimitExceeded} - If the error is a rate limit exceeded error (status code 429).
* @throws {Error} - If the error is a client-side error or an unknown error.
* @throws {Error} - If the error is a non-network related error.
*/
private handleError(error: unknown) {
let exception;

if (isAxiosError(error)) {
const statusCode = error.response?.status ?? 0;
if (statusCode >= 500) {
exception = new ApiNotAvailable("Coingecko");
} else if (statusCode === 429) {
exception = new RateLimitExceeded();
} else {
exception = new Error(
error.response?.data || "An error occurred while fetching data",
);
}

throw exception;
} else {
this.logger.error(error);
throw new Error("A non network related error occurred");
}
}
}
11 changes: 11 additions & 0 deletions libs/pricing/src/types/tokenPrice.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type TokenPrice = {
usd: number;
usd_market_cap?: number;
usd_24h_vol?: number;
usd_24h_change?: number;
last_updated_at?: number;
};

export type TokenPrices = {
[address: string]: TokenPrice;
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@nestjs/axios": "3.0.2",
"@nestjs/common": "10.0.0",
"@nestjs/core": "10.0.0",
"@nestjs/platform-express": "10.0.0",
"@nestjs/swagger": "7.4.0",
"abitype": "1.0.5",
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"nest-winston": "1.9.7",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
Expand Down
Loading

0 comments on commit b3a0e96

Please sign in to comment.