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..64bf36d 100644 --- a/libs/pricing/src/pricing.module.ts +++ b/libs/pricing/src/pricing.module.ts @@ -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], }) diff --git a/libs/pricing/src/services/coingecko.service.spec.ts b/libs/pricing/src/services/coingecko.service.spec.ts index 2c58646..2dcbc4a 100644 --- a/libs/pricing/src/services/coingecko.service.spec.ts +++ b/libs/pricing/src/services/coingecko.service.spec.ts @@ -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({ + axiosRef: createMock(), + }), }, ], }).compile(); service = module.get(CoingeckoService); + httpService = module.get(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(), + config: { headers: createMock() }, + }), + ); + + 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(), + config: { headers: createMock() }, + }), + ); + + 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, + ), + ); + }); + }); }); diff --git a/libs/pricing/src/services/coingecko.service.ts b/libs/pricing/src/services/coingecko.service.ts index 1d853eb..dfcb566 100644 --- a/libs/pricing/src/services/coingecko.service.ts +++ b/libs/pricing/src/services/coingecko.service.ts @@ -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> { - throw new Error("Method not implemented."); + const { currency } = config; + return this.get("/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(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", + ); + } + + 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 7d70ce9..872a868 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d92be1..42ed831 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,9 @@ 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 reflect-metadata: specifier: 0.1.13 version: 0.1.13 @@ -537,6 +543,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'} @@ -1109,6 +1122,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + 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} @@ -1728,6 +1744,15 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + 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'} @@ -2669,6 +2694,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==} @@ -3930,6 +3958,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) @@ -4547,6 +4581,14 @@ snapshots: asynckit@0.4.0: {} + 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 @@ -5285,6 +5327,8 @@ snapshots: flatted@3.3.1: {} + 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 @@ -6339,6 +6383,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