diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d208dd..86c1ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,29 +5,96 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.0] - 2024-03-24 +## [2.0.0] - 2025-10-26 ### Added -- Initial release -- Basic virtual card operations (create, get, update, cancel, close) -- Recurring card support -- Transaction listing and filtering -- Comprehensive test suite (unit and integration tests) -- Jupyter notebook examples -- Type hints and validation -- Async/await support + +- `extend.auth` module providing reusable `Authorization` strategies (e.g., `BasicAuth`, `BearerAuth`). +- Support for injecting any `Authorization` implementation into both `APIClient` and `ExtendClient`. + +### Changed + +- **Breaking:** `APIClient` and `ExtendClient` constructors now require an `Authorization` instance instead of raw + `api_key`/`api_secret` strings. Wrap credentials in `BasicAuth` or supply a different `Authorization` to upgrade. +- Updated documentation, examples, and tests to reflect the new initialization pattern. + +## [1.2.2] - 2025-09-30 + +### Added + +- `Transactions.get_transactions` now accepts multiple status values plus a `missing_expense_categories` flag to focus + on + records that still need categorization. +- Expanded unit and integration coverage validating the new filters along with stricter date-range assertions. + +### Changed + +- Transaction queries now send API-native parameter names (`perPage`, `since`, `until`) and emit `receiptStatus`/ + `expenseCategoryStatuses` lists whenever those filters are active. +- Status inputs are validated and normalized to uppercase before requests to avoid accidental API errors when multiple + values are provided. + +## [1.2.1] - 2025-04-11 + +### Added + +- A `receipt_missing` flag on transaction listings for fetching only records that still need receipts, plus helper + utilities/tests that assert attachment counts against that filter. + +### Changed + +- Receipt reminder flows now explicitly request `receipt_missing=True`, and the integration test tolerates HTTP 429 + rate limits while still verifying behavior. +- Transaction listing tests share a `get_transactions_from_response` helper to keep assertions consistent. + +## [1.2.0] - 2025-04-10 + +### Added + +- `Transactions.send_receipt_reminder` for nudging cardholders, with accompanying unit and integration tests. +- `APIClient.post_multipart` and a shared `_send_request` helper, enabling consistent handling of multipart uploads and + HTTP verbs. ### Changed + +- All HTTP methods now funnel through `_send_request`, unifying timeout/error handling and simplifying future + instrumentation. + +## [1.1.0] - 2025-04-08 + +### Added + +- New `ReceiptCapture` resource (exposed via `ExtendClient.receipt_capture`) that supports bulk receipt automatching and + status polling. +- Tests covering the new receipt-capture endpoints alongside improvements to receipt attachments and documentation. + +## [1.0.0] - 2025-04-04 + +### Added + +- First public release of the async Extend Python SDK, including typed models, validation utilities, and resource + wrappers for credit cards, virtual cards, transactions, expense data, and receipt uploads. +- Developer tooling essentials: packaging metadata, Makefile shortcuts, `.env` template, CI release workflow, and + contribution guidelines. +- Comprehensive automation (unit + integration tests) plus the initial Jupyter notebook for interactive API + exploration. + +### Changed + - None (initial release) ### Deprecated + - None (initial release) ### Removed + - None (initial release) ### Fixed + - None (initial release) ### Security -- None (initial release) \ No newline at end of file + +- None (initial release) diff --git a/README.md b/README.md index e1be4be..e031b35 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,16 @@ pip install -e . ```python import asyncio from extend import ExtendClient +from extend.auth import BasicAuth async def main(): # Initialize the client client = ExtendClient( - api_key="your-api-key", - api_secret="your-api-secret" + auth=BasicAuth( + "your-api-key", + "your-api-secret", + ) ) # Get all virtual cards @@ -64,6 +67,27 @@ async def main(): asyncio.run(main()) ``` +### Using Custom Authorization + +Both `ExtendClient` and `APIClient` accept reusable authorization strategies defined in `extend.auth`, enabling scenarios like JWT-based access or shared credentials across clients. + +```python +from extend import ExtendClient +from extend.auth import BearerAuth + +auth = BearerAuth(jwt_token="your-jwt-token") +client = ExtendClient(auth=auth) +``` + +If you want to work with the lower-level `APIClient` directly, you can pass any `Authorization` implementation: + +```python +from extend.auth import BasicAuth +from extend.client import APIClient + +api_client = APIClient(auth=BasicAuth("your-api-key", "your-api-secret")) +``` + ## Environment Variables The following environment variables are required for integration tests and examples: diff --git a/extend/__version__.py b/extend/__version__.py index bc86c94..8c0d5d5 100644 --- a/extend/__version__.py +++ b/extend/__version__.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "2.0.0" diff --git a/extend/auth.py b/extend/auth.py new file mode 100644 index 0000000..b952d63 --- /dev/null +++ b/extend/auth.py @@ -0,0 +1,34 @@ +import base64 +from abc import ABC, abstractmethod +from typing import Dict + +from .config import API_VERSION + + +class Authorization(ABC): + @abstractmethod + def get_auth_headers(self) -> Dict[str, str]: + pass + + +class BasicAuth(Authorization): + def __init__(self, api_key: str, api_secret: str, api_version: str = API_VERSION): + self.api_key = api_key + self.api_secret = api_secret + self.api_version = api_version + + def get_auth_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Basic {base64.b64encode(f"{self.api_key}:{self.api_secret}".encode()).decode()}", + "x-extend-api-key": self.api_key, + "Accept": self.api_version, + } + + +class BearerAuth(Authorization): + def __init__(self, jwt_token: str, api_version: str = API_VERSION): + self.jwt_token = jwt_token + self.api_version = api_version + + def get_auth_headers(self) -> Dict[str, str]: + return {"Authorization": f"Bearer {self.jwt_token}", "Accept": self.api_version} diff --git a/extend/client.py b/extend/client.py index fd06a04..6dc638c 100644 --- a/extend/client.py +++ b/extend/client.py @@ -1,52 +1,45 @@ -import base64 from typing import Optional, Dict, Any import httpx -from .config import API_HOST, API_VERSION +from .auth import Authorization +from .config import API_HOST class APIClient: """Client for interacting with the Extend API. Args: - api_key (str): Your Extend API key - api_secret (str): Your Extend API secret + auth (Authorization): Authorization strategy that yields request headers. Example: ```python - client = ExtendAPI(api_key="your_key", api_secret="your_secret") + from extend.auth import BasicAuth + + client = APIClient(auth=BasicAuth("your_key", "your_secret")) cards = await client.get_virtual_cards() ``` """ _shared_instance: Optional["APIClient"] = None - def __init__(self, api_key: str, api_secret: str): + def __init__(self, auth: Authorization): """Initialize the Extend API client. - + Args: - api_key (str): Your Extend API key - api_secret (str): Your Extend API secret + auth (Authorization): Authorization strategy to use for requests. """ - auth_value = base64.b64encode(f"{api_key}:{api_secret}".encode()).decode() - self.headers = { - "x-extend-api-key": api_key, - "Authorization": f"Basic {auth_value}", - "Accept": API_VERSION - } + headers = dict(auth.get_auth_headers()) + + self._auth = auth + self.headers = headers @classmethod - def shared_instance(cls, api_key: Optional[str] = None, api_secret: Optional[str] = None) -> "APIClient": - """ - Returns a singleton instance of APIClient. On first call, you must provide both - api_key and api_secret. Subsequent calls return the same instance. - """ + def shared_instance(cls, auth: Authorization) -> "APIClient": + """Returns a singleton instance of APIClient using the provided authorization.""" if cls._shared_instance is None: - if api_key is None or api_secret is None: - raise ValueError("API key and API secret must be provided on the first call to global_instance.") - cls._global_instance = cls(api_key, api_secret) - return cls._global_instance + cls._shared_instance = cls(auth=auth) + return cls._shared_instance # ---------------------------------------- # HTTP Methods diff --git a/extend/extend.py b/extend/extend.py index d57bc69..aa44519 100644 --- a/extend/extend.py +++ b/extend/extend.py @@ -1,4 +1,5 @@ from extend.resources.virtual_cards import VirtualCards +from .auth import Authorization from .client import APIClient from .resources.credit_cards import CreditCards from .resources.expense_data import ExpenseData @@ -11,24 +12,24 @@ class ExtendClient: """Wrapper around Extend API Args: - api_key (str): Your Extend API key - api_secret (str): Your Extend API secret + auth (Authorization): Authorization instance shared with the internal API client. Example: ```python - extend = ExtendClient(api_key="your_key", api_secret="your_secret") + from extend.auth import BasicAuth + + extend = ExtendClient(auth=BasicAuth("your_key", "your_secret")) cards = await extend.get_virtual_cards() ``` """ - def __init__(self, api_key: str, api_secret: str): + def __init__(self, auth: Authorization): """Initialize the Extend Client. Args: - api_key (str): Your Extend API key - api_secret (str): Your Extend API secret + auth (Authorization): Authorization strategy shared with the underlying API client. """ - self._api_client = APIClient(api_key=api_key, api_secret=api_secret) + self._api_client = APIClient(auth=auth) self.credit_cards = CreditCards(self._api_client) self.virtual_cards = VirtualCards(self._api_client) self.transactions = Transactions(self._api_client) diff --git a/tests/simple_test.py b/tests/simple_test.py index b0d5f79..0fae372 100644 --- a/tests/simple_test.py +++ b/tests/simple_test.py @@ -4,13 +4,14 @@ from dotenv import load_dotenv from extend import ExtendClient +from extend.auth import BasicAuth load_dotenv() # Initialize the client api_key = os.getenv("EXTEND_API_KEY") api_secret = os.getenv("EXTEND_API_SECRET") -extend = ExtendClient(api_key, api_secret) +extend = ExtendClient(auth=BasicAuth(api_key, api_secret)) async def test_virtual_cards(): diff --git a/tests/test_client.py b/tests/test_client.py index c70ee87..944c203 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +import base64 import os from datetime import datetime, timedelta from typing import Any, Dict, List @@ -7,14 +8,41 @@ from dotenv import load_dotenv from extend import VirtualCard, Transaction, validations, ExtendClient +from extend.auth import BearerAuth, BasicAuth +from extend.client import APIClient +from extend.config import API_VERSION load_dotenv() +def test_api_client_accepts_auth_instance(): + auth = BearerAuth(jwt_token="test-token") + client = APIClient(auth=auth) + + assert client.headers["Authorization"] == "Bearer test-token" + assert client.headers["Accept"] == API_VERSION + + +def test_api_client_basic_auth_from_keys(): + auth = BasicAuth("key", "secret") + client = APIClient(auth=auth) + + expected_basic = base64.b64encode(b"key:secret").decode() + assert client.headers["x-extend-api-key"] == "key" + assert client.headers["Authorization"] == f"Basic {expected_basic}" + + +def test_api_client_requires_auth_instance(): + with pytest.raises(TypeError): + APIClient() + + @pytest.fixture(scope="session") def extend(): # Initialize the API client - return ExtendClient(os.getenv("EXTEND_API_KEY"), os.getenv("EXTEND_API_SECRET")) + api_key = os.getenv("EXTEND_API_KEY", "test-key") + api_secret = os.getenv("EXTEND_API_SECRET", "test-secret") + return ExtendClient(auth=BasicAuth(api_key, api_secret)) @pytest.fixture @@ -272,7 +300,7 @@ async def test_get_transactions_receipt_missing_param(extend, mocker, mock_trans assert mock_get.call_count == 1 _, params = mock_get.call_args[0] assert params["receiptMissing"] is True - assert params["receiptStatus"] == "Missing" + assert params["receiptStatus"] == ["Missing"] @pytest.mark.asyncio diff --git a/tests/test_integration.py b/tests/test_integration.py index 54d9ce3..1bbe238 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,6 +9,7 @@ from dotenv import load_dotenv from extend import ExtendClient +from extend.auth import BasicAuth load_dotenv() @@ -29,7 +30,7 @@ def extend(): """Create a real API client for integration testing""" api_key = os.getenv("EXTEND_API_KEY") api_secret = os.getenv("EXTEND_API_SECRET") - return ExtendClient(api_key, api_secret) + return ExtendClient(auth=BasicAuth(api_key, api_secret)) @pytest.fixture(scope="session")