From f307c361091b34a14e05b7057e54e48ff9c391fc Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:21:42 -0300 Subject: [PATCH] feat: replace wrapped nest axios with raw instance --- libs/pricing/src/pricing.module.ts | 3 +- .../src/services/coingecko.service.spec.ts | 115 ++++++++---------- .../pricing/src/services/coingecko.service.ts | 102 ++++++++-------- package.json | 1 + pnpm-lock.yaml | 20 +++ 5 files changed, 127 insertions(+), 114 deletions(-) diff --git a/libs/pricing/src/pricing.module.ts b/libs/pricing/src/pricing.module.ts index 64bf36d..4be4550 100644 --- a/libs/pricing/src/pricing.module.ts +++ b/libs/pricing/src/pricing.module.ts @@ -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], }) diff --git a/libs/pricing/src/services/coingecko.service.spec.ts b/libs/pricing/src/services/coingecko.service.spec.ts index 66d384c..38aeb37 100644 --- a/libs/pricing/src/services/coingecko.service.spec.ts +++ b/libs/pricing/src/services/coingecko.service.spec.ts @@ -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"; @@ -11,8 +9,10 @@ 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({ @@ -20,30 +20,38 @@ describe("CoingeckoService", () => { 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({ - axiosRef: createMock(), - }), }, ], }).compile(); service = module.get(CoingeckoService); - httpService = module.get(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"]; @@ -53,7 +61,7 @@ describe("CoingeckoService", () => { token2: { usd: 4.56 }, }; - jest.spyOn(httpService.axiosRef, "get").mockResolvedValueOnce({ + jest.spyOn(axios, "get").mockResolvedValueOnce({ data: expectedResponse, }); @@ -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(), - config: { headers: createMock() }, - }), - ); + mockAxios.onGet().replyOnce(503, { + data: {}, + status: 503, + statusText: "Service not available", + }); await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow( new ApiNotAvailable("Coingecko"), @@ -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(), - config: { headers: createMock() }, - }), - ); + mockAxios.onGet().replyOnce(429, { + data: {}, + status: 429, + statusText: "Too Many Requests", + }); await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow( new RateLimitExceeded(), @@ -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(); }); }); }); diff --git a/libs/pricing/src/services/coingecko.service.ts b/libs/pricing/src/services/coingecko.service.ts index e6e8c6b..b6a2dd5 100644 --- a/libs/pricing/src/services/coingecko.service.ts +++ b/libs/pricing/src/services/coingecko.service.ts @@ -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"; @@ -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; /** * @@ -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. @@ -36,57 +49,50 @@ export class CoingeckoService implements IPricingService { config: { currency: string } = { currency: "usd" }, ): Promise> { const { currency } = config; - return this.httpGet("/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("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(endpoint: string, params: Record = {}) { - try { - const response = await this.httpService.axiosRef.get( - `${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"); } } } diff --git a/package.json b/package.json index 872a868..5a60254 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ed831..020f2d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: axios: specifier: 1.7.2 version: 1.7.2 + axios-mock-adapter: + specifier: 1.22.0 + version: 1.22.0(axios@1.7.2) reflect-metadata: specifier: 0.1.13 version: 0.1.13 @@ -1122,6 +1125,11 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios-mock-adapter@1.22.0: + resolution: {integrity: sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw==} + peerDependencies: + axios: '>= 0.17.0' + axios@1.7.2: resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} @@ -1990,6 +1998,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-core-module@2.14.0: resolution: {integrity: sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==} engines: {node: '>= 0.4'} @@ -4581,6 +4593,12 @@ snapshots: asynckit@0.4.0: {} + axios-mock-adapter@1.22.0(axios@1.7.2): + dependencies: + axios: 1.7.2 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + axios@1.7.2: dependencies: follow-redirects: 1.15.6 @@ -5575,6 +5593,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@2.0.5: {} + is-core-module@2.14.0: dependencies: hasown: 2.0.2