Skip to content

Commit

Permalink
feat: get token prices ep
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Jul 29, 2024
1 parent 080dd7c commit b290492
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 6 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);
}
}
2 changes: 2 additions & 0 deletions libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";

import { CoingeckoService } from "./services";

@Module({
imports: [HttpModule],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
120 changes: 118 additions & 2 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,144 @@
import { createMock } from "@golevelup/ts-jest";
import { HttpService } from "@nestjs/axios";
import { HttpException, HttpStatus } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AxiosError, AxiosInstance, AxiosResponseHeaders } from "axios";

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

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

describe("CoingeckoService", () => {
let service: CoingeckoService;
let httpService: HttpService;
const apiKey = "COINGECKO_API_KEY";

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CoingeckoService,
{
provide: CoingeckoService,
useFactory: () => {
useFactory: (httpService: HttpService) => {
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";
return new CoingeckoService(apiKey, apiBaseUrl);
return new CoingeckoService(apiKey, apiBaseUrl, httpService);
},
inject: [HttpService],
},
{
provide: HttpService,
useValue: createMock<HttpService>({
axiosRef: createMock<AxiosInstance>(),
}),
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
httpService = module.get<HttpService>(HttpService);
});

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

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(httpService.axiosRef, "get").mockResolvedValueOnce({
data: expectedResponse,
});

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

expect(result).toEqual({
token1: 1.23,
token2: 4.56,
});
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
`${service["apiBaseUrl"]}/simple/price`,
{
params: {
vs_currencies: currency,
ids: tokenIds.join(","),
},
headers: {
"x-cg-pro-api-key": apiKey,
Accept: "application/json",
},
},
);
});

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

jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Service not available", "503", undefined, null, {
status: 503,
data: {},
statusText: "Too Many Requests",
headers: createMock<AxiosResponseHeaders>(),
config: { headers: createMock<AxiosResponseHeaders>() },
}),
);

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";

jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Rate limit exceeded", "429", undefined, null, {
status: 429,
data: {},
statusText: "Too Many Requests",
headers: createMock<AxiosResponseHeaders>(),
config: { headers: createMock<AxiosResponseHeaders>() },
}),
);

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(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Invalid token ID", "400"),
);

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

it("throw an HttpException with the default error message when a non-network related error occurs", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(new Error());

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new HttpException(
"A non network related error occurred",
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
});
});
});
57 changes: 53 additions & 4 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { isAxiosError } from "axios";

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

@Injectable()
export class CoingeckoService implements IPricingService {
private readonly logger = new Logger(CoingeckoService.name);

private readonly AUTH_HEADER = "x-cg-pro-api-key";
constructor(
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
private readonly httpService: HttpService,
) {}

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.get<TokenPrices>("/simple/price", {
vs_currencies: currency,
ids: tokenIds.join(","),
}).then((data) => {
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, value.usd]));
});
}

private async get<ResponseType>(endpoint: string, params: Record<string, string> = {}) {
try {
const response = await this.httpService.axiosRef.get<ResponseType>(
`${this.apiBaseUrl}${endpoint}`,
{
params,
headers: {
[this.AUTH_HEADER]: this.apiKey,
Accept: "application/json",
},
},
);
return response.data;
} catch (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;
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@
"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",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
"viem": "2.17.5"
Expand Down
46 changes: 46 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit b290492

Please sign in to comment.