diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index 85be8a09..524c3fb8 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,4 +1,3 @@ -import { AxiosError } from "axios"; import { Address as AddressModel } from "../client"; import { InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; @@ -36,18 +35,11 @@ export class Address { * @throws {Error} If the request fails. */ async faucet(): Promise { - try { - const response = await this.client.requestFaucetFunds( - this.model.wallet_id, - this.model.address_id, - ); - return new FaucetTransaction(response.data); - } catch (e) { - if (e instanceof AxiosError) { - throw e; - } - throw new Error(`Failed to complete faucet request`); - } + const response = await this.client.requestFaucetFunds( + this.model.wallet_id, + this.model.address_id, + ); + return new FaucetTransaction(response.data); } /** diff --git a/src/coinbase/api_error.ts b/src/coinbase/api_error.ts new file mode 100644 index 00000000..96d2683a --- /dev/null +++ b/src/coinbase/api_error.ts @@ -0,0 +1,130 @@ +import { AxiosError } from "axios"; +import { InternalError } from "./errors"; + +/** + * The API error response type. + */ +type APIErrorResponseType = { + code: string; + message: string; +}; + +/** + * A wrapper for API errors to provide more context. + */ +export class APIError extends AxiosError { + httpCode: number | null; + apiCode: string | null; + apiMessage: string | null; + + /** + * Initializes a new APIError object. + * @constructor + * @param {AxiosError} error - The Axios error. + */ + constructor(error) { + super(); + this.name = this.constructor.name; + this.httpCode = error.response ? error.response.status : null; + this.apiCode = null; + this.apiMessage = null; + + if (error.response && error.response.data) { + const body = error.response.data; + this.apiCode = body.code; + this.apiMessage = body.message; + } + } + + /** + * Creates a specific APIError based on the API error code. + * @param {AxiosError} error - The underlying error object. + * @returns {APIError} A specific APIError instance. + */ + static fromError(error: AxiosError) { + const apiError = new APIError(error); + if (!error.response || !error.response.data) { + return apiError; + } + + const body = error?.response?.data as APIErrorResponseType; + switch (body?.code) { + case "unimplemented": + return new UnimplementedError(error); + case "unauthorized": + return new UnauthorizedError(error); + case "internal": + return new InternalError(error.message); + case "not_found": + return new NotFoundError(error); + case "invalid_wallet_id": + return new InvalidWalletIDError(error); + case "invalid_address_id": + return new InvalidAddressIDError(error); + case "invalid_wallet": + return new InvalidWalletError(error); + case "invalid_address": + return new InvalidAddressError(error); + case "invalid_amount": + return new InvalidAmountError(error); + case "invalid_transfer_id": + return new InvalidTransferIDError(error); + case "invalid_page_token": + return new InvalidPageError(error); + case "invalid_page_limit": + return new InvalidLimitError(error); + case "already_exists": + return new AlreadyExistsError(error); + case "malformed_request": + return new MalformedRequestError(error); + case "unsupported_asset": + return new UnsupportedAssetError(error); + case "invalid_asset_id": + return new InvalidAssetIDError(error); + case "invalid_destination": + return new InvalidDestinationError(error); + case "invalid_network_id": + return new InvalidNetworkIDError(error); + case "resource_exhausted": + return new ResourceExhaustedError(error); + case "faucet_limit_reached": + return new FaucetLimitReachedError(error); + case "invalid_signed_payload": + return new InvalidSignedPayloadError(error); + case "invalid_transfer_status": + return new InvalidTransferStatusError(error); + default: + return apiError; + } + } + + /** + * Returns a String representation of the APIError. + * @returns {string} a String representation of the APIError + */ + toString() { + return `APIError{httpCode: ${this.httpCode}, apiCode: ${this.apiCode}, apiMessage: ${this.apiMessage}}`; + } +} + +export class UnimplementedError extends APIError {} +export class UnauthorizedError extends APIError {} +export class NotFoundError extends APIError {} +export class InvalidWalletIDError extends APIError {} +export class InvalidAddressIDError extends APIError {} +export class InvalidWalletError extends APIError {} +export class InvalidAddressError extends APIError {} +export class InvalidAmountError extends APIError {} +export class InvalidTransferIDError extends APIError {} +export class InvalidPageError extends APIError {} +export class InvalidLimitError extends APIError {} +export class AlreadyExistsError extends APIError {} +export class MalformedRequestError extends APIError {} +export class UnsupportedAssetError extends APIError {} +export class InvalidAssetIDError extends APIError {} +export class InvalidDestinationError extends APIError {} +export class InvalidNetworkIDError extends APIError {} +export class ResourceExhaustedError extends APIError {} +export class FaucetLimitReachedError extends APIError {} +export class InvalidSignedPayloadError extends APIError {} +export class InvalidTransferStatusError extends APIError {} diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index ea80bc43..29cd577a 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,13 +1,14 @@ import globalAxios from "axios"; import fs from "fs"; -import { UsersApiFactory, User as UserModel } from "../client"; +import { User as UserModel, UsersApiFactory } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; +import { APIError } from "./api_error"; import { CoinbaseAuthenticator } from "./authenticator"; +import { InternalError, InvalidAPIKeyFormat, InvalidConfiguration } from "./errors"; import { ApiClients } from "./types"; import { User } from "./user"; -import { logApiResponse } from "./utils"; -import { InvalidAPIKeyFormat, InternalError, InvalidConfiguration } from "./errors"; +import { logApiResponse, registerAxiosInterceptors } from "./utils"; // The Coinbase SDK. export class Coinbase { @@ -40,10 +41,12 @@ export class Coinbase { basePath: basePath, }); const axiosInstance = globalAxios.create(); - axiosInstance.interceptors.request.use(config => - coinbaseAuthenticator.authenticateRequest(config, debugging), + registerAxiosInterceptors( + axiosInstance, + config => coinbaseAuthenticator.authenticateRequest(config, debugging), + response => logApiResponse(response, debugging), ); - axiosInstance.interceptors.response.use(response => logApiResponse(response, debugging)); + this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); } @@ -85,14 +88,10 @@ export class Coinbase { /** * Returns User object for the default user. * @returns {User} The default user. - * @throws {InternalError} If the request fails. + * @throws {APIError} If the request fails. */ async getDefaultUser(): Promise { - try { - const userResponse = await this.apiClients.user!.getCurrentUser(); - return new User(userResponse.data as UserModel, this.apiClients); - } catch (error) { - throw new InternalError(`Failed to retrieve user: ${(error as Error).message}`); - } + const userResponse = await this.apiClients.user!.getCurrentUser(); + return new User(userResponse.data as UserModel, this.apiClients); } } diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 784b27cb..1ebf5fc4 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -3,11 +3,11 @@ import { AddressesApiFactory, Address as AddressModel } from "../../client"; import { Address } from "./../address"; import { FaucetTransaction } from "./../faucet_transaction"; -import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { randomUUID } from "crypto"; - -const axiosMock = new MockAdapter(axios); +import { APIError, FaucetLimitReachedError } from "../api_error"; +import { createAxiosMock } from "./utils"; +import { InternalError } from "../errors"; const newEthAddress = ethers.Wallet.createRandom(); @@ -20,53 +20,82 @@ const VALID_ADDRESS_MODEL: AddressModel = { // Test suite for Address class describe("Address", () => { - const client = AddressesApiFactory(); + const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); + const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance); + let address, axiosMock; + + beforeAll(() => { + axiosMock = new MockAdapter(axiosInstance); + }); + + beforeEach(() => { + address = new Address(VALID_ADDRESS_MODEL, client); + }); afterEach(() => { axiosMock.reset(); }); - it("should create an Address instance", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); + it("should initialize a new Address", () => { expect(address).toBeInstanceOf(Address); + }); + + it("should return the network ID", () => { expect(address.getId()).toBe(newEthAddress.address); - expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + }); + + it("should return the address ID", () => { expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); + }); + + it("should return the public key", () => { + expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + }); + + it("should return the wallet ID", () => { expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id); }); - it("should throw an InternalError if model is not provided", () => { + it("should throw an InternalError when model is not provided", () => { expect(() => new Address(null!, client)).toThrow(`Address model cannot be empty`); }); - it("should throw an InternalError if client is not provided", () => { + it("should throw an InternalError when client is not provided", () => { expect(() => new Address(VALID_ADDRESS_MODEL, null!)).toThrow(`Address client cannot be empty`); }); - it("should request faucet funds and return a FaucetTransaction", async () => { + it("should request funds from the faucet and returns the faucet transaction", async () => { + const transactionHash = "0xdeadbeef"; axiosMock.onPost().reply(200, { - transaction_hash: "mocked_transaction_hash", + transaction_hash: transactionHash, }); - const address = new Address(VALID_ADDRESS_MODEL, client); const faucetTransaction = await address.faucet(); expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); - expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); + expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); + }); + + it("should throw an APIError when the request is unsuccesful", async () => { + axiosMock.onPost().reply(400); + await expect(address.faucet()).rejects.toThrow(APIError); }); - it("should request faucet funds and throw an InternalError if the request does not return a transaction hash", async () => { - axiosMock.onPost().reply(200, {}); - const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Failed to complete faucet request"); + it("should throw a FaucetLimitReachedError when the faucet limit is reached", async () => { + axiosMock.onPost().reply(429, { + code: "faucet_limit_reached", + message: "Faucet limit reached", + }); + await expect(address.faucet()).rejects.toThrow(FaucetLimitReachedError); }); - it("should throw an AxiosError if faucet request fails", async () => { - axiosMock.onPost().reply(400); - const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Request failed with status code 400"); + it("should throw an InternalError when the request fails unexpectedly", async () => { + axiosMock.onPost().reply(500, { + code: "internal", + message: "unexpected error occurred while requesting faucet funds", + }); + await expect(address.faucet()).rejects.toThrow(InternalError); }); it("should return the correct string representation", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); expect(address.toString()).toBe( `Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`, ); diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index ca5cb423..ae5af200 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -1,6 +1,7 @@ import { Coinbase } from "../coinbase"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; +import { APIError } from "../api_error"; const axiosMock = new MockAdapter(axios); const PATH_PREFIX = "./src/coinbase/tests/config"; @@ -54,8 +55,6 @@ describe("Coinbase tests", () => { it("should raise an error if the user is not found", async () => { axiosMock.onGet().reply(404); const cbInstance = Coinbase.configureFromJson(`${PATH_PREFIX}/coinbase_cloud_api_key.json`); - await expect(cbInstance.getDefaultUser()).rejects.toThrow( - "Failed to retrieve user: Request failed with status code 404", - ); + await expect(cbInstance.getDefaultUser()).rejects.toThrow(APIError); }); }); diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts new file mode 100644 index 00000000..d9c961b7 --- /dev/null +++ b/src/coinbase/tests/utils.ts @@ -0,0 +1,24 @@ +import axios, { AxiosInstance } from "axios"; +import { Configuration } from "../../client"; +import { BASE_PATH } from "../../client/base"; +import { registerAxiosInterceptors } from "../utils"; + +/** + * AxiosMockReturn type. Represents the Axios instance, configuration, and base path. + */ +type AxiosMockType = [AxiosInstance, Configuration, string]; + +/** + * Returns an Axios instance with interceptors and configuration for testing. + * @returns {AxiosMockType} - The Axios instance, configuration, and base path. + */ +export const createAxiosMock = (): AxiosMockType => { + const axiosInstance = axios.create(); + registerAxiosInterceptors( + axiosInstance, + request => request, + response => response, + ); + const configuration = new Configuration(); + return [axiosInstance, configuration, BASE_PATH]; +}; diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index 42923843..8e34b314 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -1,4 +1,6 @@ -import { AxiosResponse } from "axios"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Axios, AxiosResponse, InternalAxiosRequestConfig } from "axios"; +import { APIError } from "./api_error"; /** * Prints Axios response to the console for debugging purposes. @@ -20,3 +22,36 @@ export const logApiResponse = (response: AxiosResponse, debugging = false): Axio } return response; }; + +/** + * Axios Request interceptor function type. + * @param {InternalAxiosRequestConfig} value - The Axios request configuration. + * @returns {InternalAxiosRequestConfig} The modified Axios request configuration. + */ +type RequestFunctionType = ( + value: InternalAxiosRequestConfig, +) => Promise | InternalAxiosRequestConfig; + +/** + * Axios Response interceptor function type. + * @param {AxiosResponse} value - The Axios response object. + * @returns {AxiosResponse} The modified Axios response object. + */ +type ResponseFunctionType = (value: AxiosResponse) => AxiosResponse; + +/** + * + * @param {Axios} axiosInstance - The Axios instance to register the interceptors. + * @param {RequestFunctionType} requestFn - The request interceptor function. + * @param {ResponseFunctionType} responseFn - The response interceptor function. + */ +export const registerAxiosInterceptors = ( + axiosInstance: Axios, + requestFn: RequestFunctionType, + responseFn: ResponseFunctionType, +) => { + axiosInstance.interceptors.request.use(requestFn); + axiosInstance.interceptors.response.use(responseFn, error => { + return Promise.reject(APIError.fromError(error)); + }); +};