diff --git a/CHANGELOG.md b/CHANGELOG.md index 01db411..571870a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.0.2] - 2024-JAN-10 + +### Added +- Support for files for using JSON files for API key and secret +- Improve user facing messages for common errors + ## [1.0.1] - 2024-JAN-3 ### Added diff --git a/README.md b/README.md index 0e9136a..f069fc5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Coinbase Advanced Trading API Python SDK +# Coinbase Advanced API Python SDK [![PyPI version](https://badge.fury.io/py/coinbase-advanced-py.svg)](https://badge.fury.io/py/coinbase-advanced-py) [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/license/apache-2-0/) [![Code Style](https://img.shields.io/badge/code_style-black-black)](https://black.readthedocs.io/en/stable/) @@ -40,6 +40,15 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV client = RESTClient(api_key=api_key, api_secret=api_secret) ``` +After creating your API key, a json file will be downloaded to your computer. It's possible to pass in the path to this file as an argument: +```python +client = RESTClient(key_file="path/to/coinbase_cloud_api_key.json") +``` +We also support passing a file-like object as the `key_file` argument: +```python +from io import StringIO +client = RESTClient(key_file=StringIO('{"name": "key-name", "privateKey": "private-key"}')) +``` You can also set a timeout in seconds for your REST requests like so: ```python client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5) diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 5c4105c..7863915 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index ff98ef2..4b734b8 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -8,8 +8,17 @@ def build_jwt(key_var, secret_var, service, uri=None): - private_key_bytes = secret_var.encode("utf-8") - private_key = serialization.load_pem_private_key(private_key_bytes, password=None) + try: + private_key_bytes = secret_var.encode("utf-8") + private_key = serialization.load_pem_private_key( + private_key_bytes, password=None + ) + except ValueError as e: + # This handles errors like incorrect key format + raise Exception( + f"{e}\n" + "Are you sure you generated your key at https://cloud.coinbase.com/access/api ?" + ) jwt_data = { "sub": key_var, diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 0947bae..4addbd5 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -1,6 +1,6 @@ import json import os -from typing import Optional +from typing import IO, Optional, Union import requests from requests.exceptions import HTTPError @@ -17,10 +17,15 @@ def handle_exception(response): reason = response.reason if 400 <= response.status_code < 500: - http_error_msg = ( - f"{response.status_code} Client Error: {reason} {response.text}" - ) - + if ( + response.status_code == 403 + and '"error_details":"Missing required scopes"' in response.text + ): + http_error_msg = f"{response.status_code} Client Error: Missing Required Scopes. Please verify your API keys include the necessary permissions." + else: + http_error_msg = ( + f"{response.status_code} Client Error: {reason} {response.text}" + ) elif 500 <= response.status_code < 600: http_error_msg = ( f"{response.status_code} Server Error: {reason} {response.text}" @@ -35,9 +40,25 @@ def __init__( self, api_key: Optional[str] = os.getenv(API_ENV_KEY), api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Union[IO, str]] = None, base_url=BASE_URL, timeout=None, ): + if (api_key is not None or api_secret is not None) and key_file is not None: + raise Exception(f"Cannot specify both api_key and key_file in constructor") + + if key_file is not None: + try: + if isinstance(key_file, str): + with open(key_file, "r") as file: + key_json = json.load(file) + else: + key_json = json.load(key_file) + api_key = key_json["name"] + api_secret = key_json["privateKey"] + except json.JSONDecodeError as e: + raise Exception(f"Error decoding JSON: {e}") + if api_key is None: raise Exception( f"Must specify env var COINBASE_API_KEY or pass api_key in constructor" diff --git a/setup.py b/setup.py index 9d70dc5..77cc0e2 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ name="coinbase-advanced-py", version=about["__version__"], license="Apache 2.0", - description="Coinbase Advanced Trade API Python SDK", + description="Coinbase Advanced API Python SDK", long_description=README, long_description_content_type="text/markdown", author="Coinbase", diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 2fea0ca..541d72a 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -1,4 +1,5 @@ import unittest +from io import StringIO from requests.exceptions import HTTPError from requests_mock import Mocker @@ -176,3 +177,39 @@ def test_server_error(self): with self.assertRaises(HTTPError): client.get("/api/v3/brokerage/accounts") + + def test_key_file_string(self): + try: + RESTClient(key_file="tests/test_api_key.json") + except Exception as e: + self.fail(f"An unexpected exception occurred: {e}") + + def test_key_file_object(self): + try: + key_file_object = StringIO( + '{"name": "test-api-key-name","privateKey": "test-api-key-private-key"}' + ) + RESTClient(key_file=key_file_object) + except Exception as e: + self.fail(f"An unexpected exception occurred: {e}") + + def test_key_file_no_key(self): + with self.assertRaises(Exception): + key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}') + RESTClient(key_file=key_file_object) + + def test_key_file_multiple_key_inputs(self): + with self.assertRaises(Exception): + key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}') + RESTClient( + api_key=TEST_API_KEY, + api_secret=TEST_API_SECRET, + key_file=key_file_object, + ) + + def test_key_file_invalid_json(self): + with self.assertRaises(Exception): + key_file_object = StringIO( + '"name": "test-api-key-name","privateKey": "test-api-key-private-key"' + ) + RESTClient(key_file=key_file_object) diff --git a/tests/test_api_key.json b/tests/test_api_key.json new file mode 100644 index 0000000..ab7b71c --- /dev/null +++ b/tests/test_api_key.json @@ -0,0 +1,13 @@ +{ + "name": "test-api-key-name", + "privateKey": "test-api-key-private-key", + "nickname": "TestApiKey", + "scopes": [ + "rat#view", + "rat#trade", + "rat#transfer" + ], + "allowedIps": [], + "keyType": "TRADING_KEY", + "enabled": true +} \ No newline at end of file diff --git a/tests/test_jwt_generator.py b/tests/test_jwt_generator.py index 3b4f303..2c9c126 100644 --- a/tests/test_jwt_generator.py +++ b/tests/test_jwt_generator.py @@ -40,3 +40,8 @@ def test_build_ws_jwt(self): self.assertEqual(decoded_data["aud"], [WS_SERVICE]) self.assertNotIn("uri", decoded_data) self.assertEqual(decoded_header["kid"], TEST_API_KEY) + + def test_build_rest_jwt_error(self): + with self.assertRaises(Exception): + uri = jwt_generator.format_jwt_uri("GET", "/api/v3/brokerage/accounts") + jwt_generator.build_rest_jwt(uri, TEST_API_KEY, "bad_secret")