Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding initial version of Coinbase Class #8

Merged
merged 12 commits into from
May 14, 2024
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,4 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@types/secp256k1": "^4.0.6",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"axios-mock-adapter": "^1.22.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
Expand Down
39 changes: 21 additions & 18 deletions src/coinbase/authenticator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { JWK, JWS } from "node-jose";
import { InternalError, InvalidAPIKeyFormat } from "./errors";
import { InternalAxiosRequestConfig } from "axios";
import { JWK, JWS } from "node-jose";
import { InvalidAPIKeyFormat } from "./errors";

const pemHeader = "-----BEGIN EC PRIVATE KEY-----";
const pemFooter = "-----END EC PRIVATE KEY-----";
Expand All @@ -22,28 +22,32 @@ export class CoinbaseAuthenticator {
}

/**
* Middleware to intercept requests and add JWT to the Authorization header for AxiosInterceptor
* @param {MiddlewareRequestType} config - The request configuration.
* @returns {MiddlewareRequestType} The request configuration with the Authorization header added.
* @throws {InternalError} If there is an issue with the private key.
* Middleware to intercept requests and add JWT to Authorization header.
* @param {InternalAxiosRequestConfig} config - The request configuration.
* @param {boolean} debugging - Flag to enable debugging.
* @returns {Promise<InternalAxiosRequestConfig>} The request configuration with the Authorization header added.
* @throws {InvalidAPIKeyFormat} If JWT could not be built.
*/
async authenticateRequest(
config: InternalAxiosRequestConfig,
debugging = false,
): Promise<InternalAxiosRequestConfig> {
const method = config.method?.toString().toUpperCase();
const token = await this.buildJWT(config.url || "", method);

if (debugging) {
console.log(`API REQUEST: ${method} ${config.url}`);
}
config.headers["Authorization"] = `Bearer ${token}`;
config.headers["Content-Type"] = "application/json";
return config;
}

/**
* Builds the JWT for the given API endpoint URI. The JWT is signed with the API key's private key.
* @param {string} url - The URI of the API endpoint.
* @param {string} method - The HTTP method of the request.
* @returns {string} The JWT if successful or throws an error.
* @throws {InternalError} If there is an issue with the private key.
* Builds the JWT for the given API endpoint URL.
* @param {string} url - URL of the API endpoint.
* @param {string} method - HTTP method of the request.
* @returns {Promise<string>} JWT token.
* @throws {InvalidAPIKeyFormat} If the private key is not in the correct format.
*/
async buildJWT(url: string, method = "GET"): Promise<string> {
const pemPrivateKey = this.extractPemKey(this.privateKey);
Expand All @@ -52,10 +56,10 @@ export class CoinbaseAuthenticator {
try {
privateKey = await JWK.asKey(pemPrivateKey, "pem");
if (privateKey.kty !== "EC") {
throw InternalError;
throw new InvalidAPIKeyFormat("Invalid key type");
}
} catch (error) {
throw InternalError;
throw new InvalidAPIKeyFormat("Could not parse the private key");
}

const header = {
Expand Down Expand Up @@ -83,7 +87,7 @@ export class CoinbaseAuthenticator {

return result as unknown as string;
} catch (err) {
throw InternalError;
throw new InvalidAPIKeyFormat("Could not sign the JWT");
}
}

Expand All @@ -94,19 +98,18 @@ export class CoinbaseAuthenticator {
* @throws {InvalidAPIKeyFormat} If the private key string is not in the correct format.
*/
private extractPemKey(privateKeyString: string): string {
// Remove all newline characters
privateKeyString = privateKeyString.replace(/\n/g, "");

if (privateKeyString.startsWith(pemHeader) && privateKeyString.endsWith(pemFooter)) {
return privateKeyString;
}

throw InvalidAPIKeyFormat;
throw new InvalidAPIKeyFormat("Invalid private key format");
}

/**
* Generates a random nonce for the JWT.
* @returns {string}
* @returns {string} The generated nonce.
*/
private nonce(): string {
const range = "0123456789";
Expand Down
95 changes: 95 additions & 0 deletions src/coinbase/coinbase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import globalAxios from "axios";
import fs from "fs";
import { UsersApiFactory, User as UserModel } from "../client";
import { BASE_PATH } from "./../client/base";
import { Configuration } from "./../client/configuration";
import { CoinbaseAuthenticator } from "./authenticator";
import { ApiClients } from "./types";
import { User } from "./user";
import { logApiResponse } from "./utils";
import { InvalidAPIKeyFormat, InternalError, InvalidConfiguration } from "./errors";

// The Coinbase SDK.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When do we use // and when do we use /* style comments? Let's have some consistency around this

Copy link
Contributor Author

@erdimaden erdimaden May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added "multiline-comment-style": ["error", "starred-block"], to eslint so with this configuration:

we use // for single-line comments.
we use /* */ for multi-line comments with a starred block style.

https://eslint.org/docs/latest/rules/multiline-comment-style

export class Coinbase {
apiClients: ApiClients = {};

/**
* Initializes the Coinbase SDK.
* @constructor
* @param {string} apiKeyName - The API key name.
* @param {string} privateKey - The private key associated with the API key.
* @param {boolean} debugging - If true, logs API requests and responses to the console.
* @param {string} basePath - The base path for the API.
* @throws {InternalError} If the configuration is invalid.
* @throws {InvalidAPIKeyFormat} If not able to create JWT token.
*/
constructor(
apiKeyName: string,
privateKey: string,
debugging = false,
basePath: string = BASE_PATH,
) {
if (apiKeyName === "" || privateKey === "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets explicitly say which one is empty?

throw new InternalError("Invalid configuration: privateKey or apiKeyName is empty");
}
const coinbaseAuthenticator = new CoinbaseAuthenticator(apiKeyName, privateKey);
const config = new Configuration({
basePath: basePath,
});
const axiosInstance = globalAxios.create();
axiosInstance.interceptors.request.use(config =>
coinbaseAuthenticator.authenticateRequest(config, debugging),
);
axiosInstance.interceptors.response.use(response => logApiResponse(response, debugging));
this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance);
}

/**
* Reads the API key and private key from a JSON file and initializes the Coinbase SDK.
* @param {string} filePath - The path to the JSON file containing the API key and private key.
* @returns {Coinbase} A new instance of the Coinbase SDK.
* @throws {InvalidAPIKeyFormat} If the file does not exist or the configuration values are missing/invalid.
* @throws {InvalidConfiguration} If the configuration is invalid.
* @throws {InvalidAPIKeyFormat} If not able to create JWT token.
*/
static configureFromJson(
filePath: string = "coinbase_cloud_api_key.json",
debugging: boolean = false,
basePath: string = BASE_PATH,
): Coinbase {
if (!fs.existsSync(filePath)) {
throw new InvalidConfiguration(`Invalid configuration: file not found at ${filePath}`);
}
try {
const data = fs.readFileSync(filePath, "utf8");
const config = JSON.parse(data) as { name: string; privateKey: string };
if (!config.name || !config.privateKey) {
throw new InvalidAPIKeyFormat("Invalid configuration: missing configuration values");
}

return new Coinbase(config.name, config.privateKey, debugging, basePath);
} catch (e) {
if (e instanceof SyntaxError) {
throw new InvalidAPIKeyFormat("Not able to parse the configuration file");
} else {
throw new InvalidAPIKeyFormat(
`An error occurred while reading the configuration file: ${(e as Error).message}`,
);
}
}
}

/**
* Returns User model for the default user.
* @returns {User} The default user.
* @throws {InternalError} If the request fails.
*/
async defaultUser(): Promise<User> {
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async defaultUser(): Promise<User> {
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}`);
}
}
}
38 changes: 35 additions & 3 deletions src/coinbase/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
export const InvalidConfiguration = new Error("Invalid configuration");
export const InvalidAPIKeyFormat = new Error("Invalid format of API private key");
export const InternalError = new Error(`Internal Error`);
export class InvalidAPIKeyFormat extends Error {
static DEFAULT_MESSAGE = "Invalid API key format";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc


constructor(message: string = InvalidAPIKeyFormat.DEFAULT_MESSAGE) {
super(message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc

this.name = "InvalidAPIKeyFormat";
if (Error.captureStackTrace) {
Error.captureStackTrace(this, InvalidAPIKeyFormat);
}
}
}

export class InternalError extends Error {
static DEFAULT_MESSAGE = "Internal Error";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc


constructor(message: string = InternalError.DEFAULT_MESSAGE) {
super(message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc

this.name = "InternalError";
if (Error.captureStackTrace) {
Error.captureStackTrace(this, InternalError);
}
}
}

export class InvalidConfiguration extends Error {
static DEFAULT_MESSAGE = "Invalid configuration";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a ticket for Errors but I will add JSDocs for the existing classes
https://jira.coinbase-corp.com/browse/PSDK-115


constructor(message: string = InvalidConfiguration.DEFAULT_MESSAGE) {
super(message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typedoc

this.name = "InvalidConfiguration";
if (Error.captureStackTrace) {
Error.captureStackTrace(this, InvalidConfiguration);
}
}
}
20 changes: 7 additions & 13 deletions src/coinbase/tests/authenticator_test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { AxiosHeaders } from "axios";
import { CoinbaseAuthenticator } from "../authenticator";

const VALID_KEY =
"organizations/0c3bbe72-ac81-46ec-946a-7cd019d6d86b/apiKeys/db813705-bf33-4e33-816c-4c3f1f54672b";
const VALID_PRIVATE_KEY =
"-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBPl8LBKrDw2Is+bxQEXa2eHhDmvIgArOhSAdmYpYQrCoAoGCCqGSM49\nAwEHoUQDQgAEQSoVSr8ImpS18thpGe3KuL9efy+L+AFdFFfCVwGgCsKvTYVDKaGo\nVmN5Bl6EJkeIQjyarEtWbmY6komwEOdnHA==\n-----END EC PRIVATE KEY-----\n";

const VALID_CONFIG = {
method: "GET",
url: "https://api.cdp.coinbase.com/platform/v1/users/me",
headers: {} as AxiosHeaders,
};

describe("Authenticator tests", () => {
const authenticator = new CoinbaseAuthenticator(VALID_KEY, VALID_PRIVATE_KEY);
const filePath = "./config/coinbase_cloud_api_key.json";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const keys = require(filePath);
const authenticator = new CoinbaseAuthenticator(keys.name, keys.privateKey);

it("should raise InvalidConfiguration error", async () => {
it("should raise InvalidConfiguration error for invalid config", async () => {
const invalidConfig = {
method: "GET",
url: "https://api.cdp.coinbase.com/platform/v1/users/me",
Expand All @@ -29,15 +27,11 @@ describe("Authenticator tests", () => {
const config = await authenticator.authenticateRequest(VALID_CONFIG);
const token = config.headers?.Authorization as string;
expect(token).toContain("Bearer ");
// length of the token should be greater than 100
expect(token?.length).toBeGreaterThan(100);
});

it("invalid pem key should raise an error", () => {
const invalidAuthenticator = new CoinbaseAuthenticator(
"test-key",
"-----BEGIN EC PRIVATE KEY-----+L+==\n-----END EC PRIVATE KEY-----\n",
);
it("invalid pem key should raise an InvalidAPIKeyFormat error", async () => {
const invalidAuthenticator = new CoinbaseAuthenticator("test-key", "-----BEGIN EC KEY-----\n");
expect(invalidAuthenticator.authenticateRequest(VALID_CONFIG)).rejects.toThrow();
});
});
62 changes: 62 additions & 0 deletions src/coinbase/tests/coinbase_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Coinbase } from "../coinbase";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";

const axiosMock = new MockAdapter(axios);
const PATH_PREFIX = "./src/coinbase/tests/config";

describe("Coinbase tests", () => {
beforeEach(() => {
axiosMock.reset();
});

it("should throw an error if the API key name or private key is empty", () => {
expect(() => new Coinbase("", "")).toThrow(
"Invalid configuration: privateKey or apiKeyName is empty",
);
});

it("should throw an error if the file does not exist", () => {
expect(() => Coinbase.configureFromJson(`${PATH_PREFIX}/does-not-exist.json`)).toThrow(
"Invalid configuration: file not found at ./src/coinbase/tests/config/does-not-exist.json",
);
});

it("should initialize the Coinbase SDK from a JSON file", () => {
const cbInstance = Coinbase.configureFromJson(`${PATH_PREFIX}/coinbase_cloud_api_key.json`);
expect(cbInstance).toBeInstanceOf(Coinbase);
});

it("should throw an error if there is an issue reading the file or parsing the JSON data", () => {
expect(() => Coinbase.configureFromJson(`${PATH_PREFIX}/invalid.json`)).toThrow(
"Invalid configuration: missing configuration values",
);
});

it("should throw an error if the JSON file is not parseable", () => {
expect(() => Coinbase.configureFromJson(`${PATH_PREFIX}/not_parseable.json`)).toThrow(
"Not able to parse the configuration file",
);
});

it("should be able to get the default user", async () => {
axiosMock.onGet().reply(200, {
id: 123,
});
const cbInstance = Coinbase.configureFromJson(
`${PATH_PREFIX}/coinbase_cloud_api_key.json`,
true,
);
const user = await cbInstance.defaultUser();
expect(user.getUserId()).toBe(123);
expect(user.toString()).toBe("Coinbase:User{userId: 123}");
});

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.defaultUser()).rejects.toThrow(
"Failed to retrieve user: Request failed with status code 404",
);
});
});
4 changes: 4 additions & 0 deletions src/coinbase/tests/config/coinbase_cloud_api_key.json
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "organizations/ej811111-bf11-4e11-111c-3e3e1e33333b",
"privateKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBPl8LBKrDw2Is+bxQEXa2eHhDmvIgArOhSAdmYpYQrCoAoGCCqGSM49\nAwEHoUQDQgAEQSoVSr8ImpS18thpGe3KuL9efy+L+AFdFFfCVwGgCsKvTYVDKaGo\nVmN5Bl6EJkeIQjyarEtWbmY6komwEOdnHA==\n-----END EC PRIVATE KEY-----\n"
}
4 changes: 4 additions & 0 deletions src/coinbase/tests/config/invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": 0,
"apiSecret": ""
}
1 change: 1 addition & 0 deletions src/coinbase/tests/config/not_parseable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not parseable content
27 changes: 27 additions & 0 deletions src/coinbase/tests/user_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { User } from "./../user";
import { ApiClients } from "./../types";
import { User as UserModel } from "./../../client/api";

describe("User Class", () => {
let mockUserModel: UserModel;
let mockApiClients: ApiClients;

beforeEach(() => {
mockUserModel = {
id: "12345",
} as UserModel;

mockApiClients = {} as ApiClients;
});

it("should initialize User instance with a valid user model and API clients, and set the user ID correctly", () => {
const user = new User(mockUserModel, mockApiClients);
expect(user).toBeInstanceOf(User);
expect(user.getUserId()).toBe(mockUserModel.id);
});

it("should return a correctly formatted string representation of the User instance", () => {
const user = new User(mockUserModel, mockApiClients);
expect(user.toString()).toBe(`Coinbase:User{userId: ${mockUserModel.id}}`);
});
});
Loading
Loading