Skip to content

Commit

Permalink
feat: replace wrapped nest axios with raw instance
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Jul 30, 2024
1 parent 48b22a0 commit f307c36
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 114 deletions.
3 changes: 1 addition & 2 deletions libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";

import { CoingeckoService } from "./services";

@Module({
imports: [HttpModule],
imports: [],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
115 changes: 51 additions & 64 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
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 { AxiosError, AxiosInstance } from "axios";
import MockAdapter from "axios-mock-adapter";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { TokenPrices } from "@zkchainhub/pricing/types/tokenPrice.type";
Expand All @@ -11,39 +9,49 @@ import { CoingeckoService } from "./coingecko.service";

describe("CoingeckoService", () => {
let service: CoingeckoService;
let httpService: HttpService;
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: (httpService: HttpService) => {
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";
return new CoingeckoService(apiKey, apiBaseUrl, httpService);
useFactory: () => {
return new CoingeckoService(apiKey, apiBaseUrl);
},
inject: [HttpService],
},
{
provide: HttpService,
useValue: createMock<HttpService>({
axiosRef: createMock<AxiosInstance>(),
}),
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
httpService = module.get<HttpService>(HttpService);
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"];
Expand All @@ -53,7 +61,7 @@ describe("CoingeckoService", () => {
token2: { usd: 4.56 },
};

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

Expand All @@ -63,35 +71,24 @@ describe("CoingeckoService", () => {
token1: 1.23,
token2: 4.56,
});
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
`${service["apiBaseUrl"]}/simple/price`,
{
params: {
vs_currencies: currency,
ids: tokenIds.join(","),
precision: service["DECIMALS_PRECISION"].toString(),
},
headers: {
"x-cg-pro-api-key": apiKey,
Accept: "application/json",
},
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";

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>() },
}),
);
mockAxios.onGet().replyOnce(503, {
data: {},
status: 503,
statusText: "Service not available",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new ApiNotAvailable("Coingecko"),
Expand All @@ -102,15 +99,11 @@ describe("CoingeckoService", () => {
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>() },
}),
);
mockAxios.onGet().replyOnce(429, {
data: {},
status: 429,
statusText: "Too Many Requests",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new RateLimitExceeded(),
Expand All @@ -121,25 +114,19 @@ describe("CoingeckoService", () => {
const tokenIds = ["invalidTokenId", "token2"];
const currency = "usd";

jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
jest.spyOn(axios, "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());
mockAxios.onGet().replyOnce(400, {
data: {
message: "Invalid token ID",
},
status: 400,
statusText: "Bad Request",
});

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

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { IPricingService } from "@zkchainhub/pricing/interfaces";
Expand All @@ -15,6 +14,7 @@ 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,8 +24,21 @@ export class CoingeckoService implements IPricingService {
constructor(
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
private readonly httpService: HttpService,
) {}
) {
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.
Expand All @@ -36,57 +49,50 @@ export class CoingeckoService implements IPricingService {
config: { currency: string } = { currency: "usd" },
): Promise<Record<string, number>> {
const { currency } = config;
return this.httpGet<TokenPrices>("/simple/price", {
vs_currencies: currency,
ids: tokenIds.join(","),
precision: this.DECIMALS_PRECISION.toString(),
}).then((data) => {
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, value.usd]));
});

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]),
);
});
}

/**
* HTTP GET wrapper to perform a GET request to the specified endpoint with optional parameters.
* Also injects the API key and sets the Accept header to "application/json".
* @param endpoint - The endpoint to send the GET request to.
* @param params - Optional parameters to include in the request.
* @returns A promise that resolves to the response data.
* @throws {ApiNotAvailable} If the Coingecko API is not available (status code >= 500).
* @throws {RateLimitExceeded} If the rate limit for the API is exceeded (status code 429).
* @throws {Error} If an error occurs while fetching data or a non-network related error occurs.
* 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 async httpGet<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",
);
}
private handleError(error: unknown) {
let exception;

throw 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 {
this.logger.error(error);
throw new Error("A non network related error occurred");
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");
}
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@nestjs/swagger": "7.4.0",
"abitype": "1.0.5",
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
"viem": "2.17.5"
Expand Down
20 changes: 20 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 f307c36

Please sign in to comment.