diff --git a/poetry.lock b/poetry.lock index 815a384..1c3dba2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -183,6 +183,26 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.14.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, + {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + [[package]] name = "async-timeout" version = "5.0.1" @@ -522,7 +542,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -692,6 +712,65 @@ files = [ {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.11" @@ -1690,4 +1769,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "58cb106b2fe91eceba084653f7b7e4318585289744b8ec2797da8c39b9335b94" +content-hash = "734f74e33a7dc083bdc6ceaabe25bf80ce2f1a277ae56fd09574b91864c9ce15" diff --git a/pyproject.toml b/pyproject.toml index 87de0a3..48edca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ packages = [{include = "shade", from = "src"}] [tool.poetry.dependencies] python = "^3.10" +httpx = "^0.28.1" stellar-sdk = "^13.2.1" [tool.poetry.group.dev.dependencies] diff --git a/src/shade/__init__.py b/src/shade/__init__.py index af2d261..76b167b 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -2,7 +2,8 @@ from types import ModuleType from typing import Optional -from .config import Environment +from .client import ShadeClient +from .config import config, Environment from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient from .errors import ( @@ -33,6 +34,7 @@ "ShadeClient", "ShadeError", "SyncHTTPClient", + "config", "api_base", ] diff --git a/src/shade/_debug.py b/src/shade/_debug.py new file mode 100644 index 0000000..994a9ff --- /dev/null +++ b/src/shade/_debug.py @@ -0,0 +1,65 @@ +import logging +from typing import Any, Mapping + +logger = logging.getLogger("shade") + +BODY_TRUNCATE_LENGTH = 2000 + + +def mask_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Return a copy of headers with sensitive values masked.""" + masked: dict[str, str] = {} + for key, value in headers.items(): + if key.lower() == "authorization": + masked[key] = _mask_authorization(value) + else: + masked[key] = value + return masked + + +def _mask_authorization(value: str) -> str: + if len(value) <= 4: + return "****" + return "*" * (len(value) - 4) + value[-4:] + + +def truncate_body(body: str, max_length: int = BODY_TRUNCATE_LENGTH) -> str: + if len(body) <= max_length: + return body + return body[:max_length] + "[truncated]" + + +def _body_to_str(body: Any) -> str: + if body is None: + return "" + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + return str(body) + + +def log_request( + method: str, + url: str, + headers: Mapping[str, str], + body: Any = None, +) -> None: + logger.debug( + "Request: %s %s | headers=%s | body=%s", + method, + url, + mask_headers(headers), + truncate_body(_body_to_str(body)), + ) + + +def log_response( + status_code: int, + headers: Mapping[str, str], + body: Any = None, +) -> None: + logger.debug( + "Response: status=%s | headers=%s | body=%s", + status_code, + mask_headers(headers), + truncate_body(_body_to_str(body)), + ) diff --git a/src/shade/client.py b/src/shade/client.py new file mode 100644 index 0000000..9557a5c --- /dev/null +++ b/src/shade/client.py @@ -0,0 +1,68 @@ +from typing import Any, Mapping, Optional + +import httpx + +from shade._debug import log_request, log_response +from shade.config import config + + +class ShadeClient: + """HTTP client for the Shade Payment Gateway API.""" + + def __init__( + self, + api_key: str, + base_url: str = "https://api.shadeprotocol.io", + debug: bool = False, + http_client: Optional[httpx.Client] = None, + ): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.debug = debug + self._http = http_client or httpx.Client() + self._owns_http_client = http_client is None + + def close(self) -> None: + if self._owns_http_client: + self._http.close() + + def __enter__(self) -> "ShadeClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _should_debug(self) -> bool: + return self.debug or config.debug + + def _default_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.api_key}"} + + def request( + self, + method: str, + path: str, + *, + headers: Optional[Mapping[str, str]] = None, + json: Any = None, + content: Optional[bytes] = None, + ) -> httpx.Response: + normalized_path = path if path.startswith("/") else f"/{path}" + url = f"{self.base_url}{normalized_path}" + request_headers = {**self._default_headers(), **(headers or {})} + + if self._should_debug(): + log_request(method, url, request_headers, content if content is not None else json) + + response = self._http.request( + method, + url, + headers=request_headers, + json=json, + content=content, + ) + + if self._should_debug(): + log_response(response.status_code, response.headers, response.text) + + return response diff --git a/src/shade/config.py b/src/shade/config.py index f29a6af..ab5bf08 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -9,6 +9,11 @@ # Set this before creating any client to route all requests to a custom host. api_base: Optional[str] = None +class Config: + """Global SDK configuration.""" + + debug: bool = False + class Environment(str, Enum): MAINNET = "mainnet" @@ -29,3 +34,6 @@ def network_passphrase(self) -> str: "testnet": Network.TESTNET_NETWORK_PASSPHRASE, } return _passphrases[self.value] + + +config = Config() diff --git a/tests/test_debug_logging.py b/tests/test_debug_logging.py new file mode 100644 index 0000000..1f16ec6 --- /dev/null +++ b/tests/test_debug_logging.py @@ -0,0 +1,133 @@ +import logging + +import httpx +import pytest + +from shade import ShadeClient, config +from shade._debug import mask_headers, truncate_body + + +@pytest.fixture(autouse=True) +def reset_config(): + original = config.debug + config.debug = False + yield + config.debug = original + + +def _mock_transport(handler): + return httpx.MockTransport(handler) + + +def test_mask_authorization_header(): + headers = {"Authorization": "Bearer sk_test_abcdefghij", "Content-Type": "application/json"} + masked = mask_headers(headers) + + assert masked["Content-Type"] == "application/json" + assert masked["Authorization"].endswith("ghij") + assert masked["Authorization"][:-4] == "*" * (len("Bearer sk_test_abcdefghij") - 4) + + +def test_mask_short_authorization_header(): + masked = mask_headers({"Authorization": "abc"}) + assert masked["Authorization"] == "****" + + +def test_truncate_body(): + body = "x" * 2500 + truncated = truncate_body(body) + + assert truncated.endswith("[truncated]") + assert len(truncated) == 2000 + len("[truncated]") + + +def test_truncate_body_under_limit(): + body = "short body" + assert truncate_body(body) == body + + +def test_debug_logs_request_and_response(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "ok"}, headers={"X-Request-Id": "req-1"}) + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_1234", + base_url="https://api.example.com", + debug=True, + http_client=http_client, + ) as client: + client.request("POST", "/payments", json={"amount": 100}) + + assert any("Request: POST https://api.example.com/payments" in record.message for record in caplog.records) + assert any("Response: status=200" in record.message for record in caplog.records) + assert not any("sk_test_secret_key_1234" in record.message for record in caplog.records) + assert any("1234" in record.message for record in caplog.records) + + +def test_debug_false_does_not_log(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="ok") + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_1234", + base_url="https://api.example.com", + debug=False, + http_client=http_client, + ) as client: + client.request("GET", "/health") + + shade_records = [record for record in caplog.records if record.name == "shade"] + assert shade_records == [] + + +def test_global_config_debug_enables_logging(caplog): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, text='{"created": true}') + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + config.debug = True + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_secret_key_5678", + base_url="https://api.example.com", + debug=False, + http_client=http_client, + ) as client: + client.request("POST", "/items") + + assert any("Request: POST https://api.example.com/items" in record.message for record in caplog.records) + assert any("Response: status=201" in record.message for record in caplog.records) + + +def test_response_body_truncated_in_logs(caplog): + long_body = "y" * 3000 + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=long_body) + + transport = _mock_transport(handler) + http_client = httpx.Client(transport=transport) + + with caplog.at_level(logging.DEBUG, logger="shade"): + with ShadeClient( + api_key="sk_test_key_abcd", + base_url="https://api.example.com", + debug=True, + http_client=http_client, + ) as client: + client.request("GET", "/large") + + response_logs = [record.message for record in caplog.records if "Response: status=200" in record.message] + assert len(response_logs) == 1 + assert "[truncated]" in response_logs[0] + assert "y" * 3000 not in response_logs[0] diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 68222f0..278748d 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,5 +1,5 @@ -import pytest from unittest.mock import patch + from shade import Gateway