Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -33,6 +34,7 @@
"ShadeClient",
"ShadeError",
"SyncHTTPClient",
"config",
"api_base",
]

Expand Down
65 changes: 65 additions & 0 deletions src/shade/_debug.py
Original file line number Diff line number Diff line change
@@ -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)),
)
68 changes: 68 additions & 0 deletions src/shade/client.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions src/shade/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -29,3 +34,6 @@ def network_passphrase(self) -> str:
"testnet": Network.TESTNET_NETWORK_PASSPHRASE,
}
return _passphrases[self.value]


config = Config()
Loading
Loading