diff --git a/libs/pricing/src/exceptions/apiNotAvailable.exception.ts b/libs/pricing/src/exceptions/apiNotAvailable.exception.ts new file mode 100644 index 0000000..d68d3ef --- /dev/null +++ b/libs/pricing/src/exceptions/apiNotAvailable.exception.ts @@ -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); + } +} diff --git a/libs/pricing/src/exceptions/index.ts b/libs/pricing/src/exceptions/index.ts new file mode 100644 index 0000000..e912b14 --- /dev/null +++ b/libs/pricing/src/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from "./apiNotAvailable.exception"; +export * from "./rateLimitExceeded.exception"; diff --git a/libs/pricing/src/exceptions/rateLimitExceeded.exception.ts b/libs/pricing/src/exceptions/rateLimitExceeded.exception.ts new file mode 100644 index 0000000..ee2e7d2 --- /dev/null +++ b/libs/pricing/src/exceptions/rateLimitExceeded.exception.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; + +export class RateLimitExceeded extends HttpException { + constructor() { + super("Rate limit exceeded.", HttpStatus.TOO_MANY_REQUESTS); + } +} diff --git a/libs/pricing/src/pricing.module.ts b/libs/pricing/src/pricing.module.ts index 5fc6280..6a5b670 100644 --- a/libs/pricing/src/pricing.module.ts +++ b/libs/pricing/src/pricing.module.ts @@ -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], }) diff --git a/libs/pricing/src/services/coingecko.service.spec.ts b/libs/pricing/src/services/coingecko.service.spec.ts index 2c58646..55da593 100644 --- a/libs/pricing/src/services/coingecko.service.spec.ts +++ b/libs/pricing/src/services/coingecko.service.spec.ts @@ -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 = { + 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); + 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(); + }); + }); }); diff --git a/libs/pricing/src/services/coingecko.service.ts b/libs/pricing/src/services/coingecko.service.ts index 1d853eb..567b5bf 100644 --- a/libs/pricing/src/services/coingecko.service.ts +++ b/libs/pricing/src/services/coingecko.service.ts @@ -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> { - throw new Error("Method not implemented."); + const { currency } = config; + + 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]), + ); + }); + } + + /** + * 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"); + } } } diff --git a/libs/pricing/src/types/tokenPrice.type.ts b/libs/pricing/src/types/tokenPrice.type.ts new file mode 100644 index 0000000..2c67a2b --- /dev/null +++ b/libs/pricing/src/types/tokenPrice.type.ts @@ -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; +}; diff --git a/package.json b/package.json index cfc175e..c72a7c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd9ed68..56ceb91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@nestjs/axios': + specifier: 3.0.2 + version: 3.0.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1) '@nestjs/common': specifier: 10.0.0 version: 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) @@ -23,6 +26,12 @@ importers: abitype: specifier: 1.0.5 version: 1.0.5(typescript@5.1.3)(zod@3.23.8) + axios: + specifier: 1.7.2 + version: 1.7.2 + axios-mock-adapter: + specifier: 1.22.0 + version: 1.22.0(axios@1.7.2) nest-winston: specifier: 1.9.7 version: 1.9.7(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(winston@3.13.1) @@ -550,6 +559,13 @@ packages: '@microsoft/tsdoc@0.15.0': resolution: {integrity: sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==} + '@nestjs/axios@3.0.2': + resolution: {integrity: sha512-Z6GuOUdNQjP7FX+OuV2Ybyamse+/e0BFdTWBX5JxpBDKA+YkdLynDgG6HTF04zy6e9zPa19UX0WA2VDoehwhXQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + axios: ^1.3.1 + rxjs: ^6.0.0 || ^7.0.0 + '@nestjs/cli@10.0.0': resolution: {integrity: sha512-14pju3ejAAUpFe1iK99v/b7Bw96phBMV58GXTSm3TcdgaI4O7UTLXTbMiUNyU+LGr/1CPIfThcWqFyKhDIC9VQ==} engines: {node: '>= 16'} @@ -1128,6 +1144,14 @@ 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==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1765,6 +1789,15 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fork-ts-checker-webpack-plugin@8.0.0: resolution: {integrity: sha512-mX3qW3idpueT2klaQXBzrIM/pHw+T0B/V9KHEvNrqijTq9NFnMZU6oreVxDYcf33P8a5cW+67PjodNHthGnNVg==} engines: {node: '>=12.13.0', yarn: '>=1.0.0'} @@ -2005,6 +2038,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'} @@ -2725,6 +2762,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} @@ -4019,6 +4059,12 @@ snapshots: '@microsoft/tsdoc@0.15.0': {} + '@nestjs/axios@3.0.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) + axios: 1.7.2 + rxjs: 7.8.1 + '@nestjs/cli@10.0.0(@swc/core@1.6.13)': dependencies: '@angular-devkit/core': 16.1.0(chokidar@3.5.3) @@ -4635,6 +4681,20 @@ 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 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-jest@29.7.0(@babel/core@7.24.7): dependencies: '@babel/core': 7.24.7 @@ -5393,6 +5453,8 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.6: {} + fork-ts-checker-webpack-plugin@8.0.0(typescript@5.1.3)(webpack@5.87.0(@swc/core@1.6.13)): dependencies: '@babel/code-frame': 7.24.7 @@ -5641,6 +5703,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@2.0.5: {} + is-core-module@2.14.0: dependencies: hasown: 2.0.2 @@ -6470,6 +6534,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pump@3.0.0: dependencies: end-of-stream: 1.4.4