Skip to content

Commit

Permalink
- Adding APIErrors Class to handle HTTP errors globally, - Creating c…
Browse files Browse the repository at this point in the history
…reateAxiosMock and registerAxiosInterceptors functions - Updating Address and Coinbase class test cases
  • Loading branch information
erdimaden committed May 15, 2024
1 parent 6598071 commit f01cf6a
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 52 deletions.
18 changes: 5 additions & 13 deletions src/coinbase/address.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AxiosError } from "axios";
import { Address as AddressModel } from "../client";
import { InternalError } from "./errors";
import { FaucetTransaction } from "./faucet_transaction";
Expand Down Expand Up @@ -36,18 +35,11 @@ export class Address {
* @throws {Error} If the request fails.
*/
async faucet(): Promise<FaucetTransaction> {
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);
}

/**
Expand Down
130 changes: 130 additions & 0 deletions src/coinbase/api_error.ts
Original file line number Diff line number Diff line change
@@ -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 {}
25 changes: 12 additions & 13 deletions src/coinbase/coinbase.ts
Original file line number Diff line number Diff line change
@@ -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";

Check failure on line 6 in src/coinbase/coinbase.ts

View workflow job for this annotation

GitHub Actions / lint

'APIError' is defined but never used
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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<User> {
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);
}
}
73 changes: 51 additions & 22 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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}'}`,
);
Expand Down
5 changes: 2 additions & 3 deletions src/coinbase/tests/coinbase_test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
24 changes: 24 additions & 0 deletions src/coinbase/tests/utils.ts
Original file line number Diff line number Diff line change
@@ -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];
};
Loading

0 comments on commit f01cf6a

Please sign in to comment.