Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from types import ModuleType
from typing import Optional

from .base import ShadeObject
from .client import ShadeClient
from .config import config, Environment
from .gateway import Gateway
from .http import AsyncHTTPClient, SyncHTTPClient
from .merchant import Merchant
from .errors import (
AuthenticationError,
InvalidRequestError,
Expand All @@ -28,11 +30,13 @@
"Gateway",
"HTTPError",
"InvalidRequestError",
"Merchant",
"NetworkError",
"NotFoundError",
"RateLimitError",
"ShadeClient",
"ShadeError",
"ShadeObject",
"SyncHTTPClient",
"config",
"api_base",
Expand Down
81 changes: 81 additions & 0 deletions src/shade/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Base class for typed Shade API resource objects.

The Shade backend speaks JSON with ``camelCase`` keys (mirroring its Prisma
schema). Python models expose the same data as ``snake_case`` attributes.
:class:`ShadeObject` handles that translation: each subclass declares its
``camelCase`` -> ``snake_case`` mapping in :attr:`ShadeObject._ALIASES` and is
constructed from an API payload via :meth:`ShadeObject.from_dict`.
"""
from __future__ import annotations

import inspect
from typing import Any, ClassVar, Dict, Mapping

from .errors import InvalidRequestError


class ShadeObject:
"""Base for API resource models with camelCase <-> snake_case mapping.

Subclasses populate :attr:`_ALIASES` with the JSON keys whose names differ
from their Python attribute (i.e. the multi-word, ``camelCase`` ones).
Single-word keys that already match their attribute need no entry.
"""

# JSON (camelCase) key -> attribute (snake_case) name.
_ALIASES: ClassVar[Dict[str, str]] = {}

@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "ShadeObject":
"""Build an instance from a raw API response body.

camelCase keys are translated to their snake_case attribute names via
:attr:`_ALIASES`; keys the constructor does not accept are ignored so
that additive backend changes do not break deserialization.
"""
if not isinstance(data, Mapping):
raise InvalidRequestError(
f"{cls.__name__}.from_dict expected a mapping, "
f"got {type(data).__name__}"
)

translated: Dict[str, Any] = {}
for key, value in data.items():
translated[cls._ALIASES.get(key, key)] = value

accepted = cls._constructor_params()
kwargs = {k: v for k, v in translated.items() if k in accepted}
return cls(**kwargs)

def to_dict(self) -> Dict[str, Any]:
"""Serialize back to a camelCase payload using the reverse of ``_ALIASES``."""
reverse = {attr: key for key, attr in self._ALIASES.items()}
result: Dict[str, Any] = {}
for attr in self._constructor_params():
result[reverse.get(attr, attr)] = getattr(self, attr)
return result

@classmethod
def _constructor_params(cls) -> frozenset[str]:
params = inspect.signature(cls).parameters
return frozenset(
name
for name, param in params.items()
if param.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
)

def __eq__(self, other: object) -> bool:
if type(self) is not type(other):
return NotImplemented
return self.to_dict() == other.to_dict() # type: ignore[attr-defined]

def __repr__(self) -> str:
fields = ", ".join(
f"{attr}={getattr(self, attr)!r}" for attr in self._constructor_params()
)
return f"{type(self).__name__}({fields})"
122 changes: 122 additions & 0 deletions src/shade/merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Merchant model.

Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names
converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python). The
:attr:`Merchant.merchant_id` field (from Prisma ``merchantId: Int``) is the
numeric identifier the Soroban contract stamps onto every invoice, making it
the bridge between the backend and the on-chain world.
"""
from __future__ import annotations

from typing import Any, ClassVar, Dict, Optional

from stellar_sdk.strkey import StrKey

from .base import ShadeObject
from .errors import InvalidRequestError


class Merchant(ShadeObject):
"""A Shade merchant account.

Construct from an API response with :meth:`ShadeObject.from_dict`, which
maps camelCase JSON keys to the snake_case attributes below. The
``address`` must be a valid Stellar ed25519 public key; anything else
raises :class:`~shade.errors.InvalidRequestError` on construction.
"""

_ALIASES: ClassVar[Dict[str, str]] = {
"merchantId": "merchant_id",
"firstName": "first_name",
"lastName": "last_name",
"businessName": "business_name",
}

def __init__(
self,
*,
id: str, # noqa: A002 - required API field name
merchant_id: int,
address: str,
active: bool,
verified: bool,
account: Optional[str] = None,
email: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
business_name: Optional[str] = None,
category: Optional[str] = None,
description: Optional[str] = None,
logo: Optional[str] = None,
webhook: Optional[str] = None,
) -> None:
self.id: str = id
self.merchant_id: int = _coerce_merchant_id(merchant_id)
self.address: str = _validate_stellar_address(address)
self.active: bool = _require_bool(active, "active")
self.verified: bool = _require_bool(verified, "verified")
self.account: Optional[str] = account
self.email: Optional[str] = email
self.first_name: Optional[str] = first_name
self.last_name: Optional[str] = last_name
self.business_name: Optional[str] = business_name
self.category: Optional[str] = category
self.description: Optional[str] = description
self.logo: Optional[str] = logo
self.webhook: Optional[str] = webhook

@property
def display_name(self) -> Optional[str]:
"""The most informative human-readable name available.

Prefers ``business_name``; falls back to the person's full name
(``"{first_name} {last_name}"`` trimmed); finally ``email``.
"""
if self.business_name:
return self.business_name
full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
if full_name:
return full_name
return self.email


def _require_bool(value: Any, param: str) -> bool:
"""Return ``value`` only if it is a real ``bool``.

Coercing here would be unsafe: ``bool("false")`` is ``True``, so a
malformed payload could silently flip a flag like ``active``.
"""
if not isinstance(value, bool):
raise InvalidRequestError(
f"{param} must be a boolean, got {value!r}",
param=param,
)
return value


def _coerce_merchant_id(value: Any) -> int:
"""Return ``value`` as an ``int``, rejecting bools and non-integers."""
if isinstance(value, bool) or not isinstance(value, (int, str)):
raise InvalidRequestError(
f"merchant_id must be an integer, got {type(value).__name__}",
param="merchant_id",
)
try:
return int(value)
except (TypeError, ValueError):
raise InvalidRequestError(
f"merchant_id must be an integer, got {value!r}",
param="merchant_id",
)


def _validate_stellar_address(address: Any) -> str:
"""Validate a Stellar ed25519 public key (starts with ``G``, 56 chars)."""
if not isinstance(address, str) or not StrKey.is_valid_ed25519_public_key(address):
raise InvalidRequestError(
f"address must be a valid Stellar public key "
f"(starts with 'G', 56 characters), got {address!r}",
param="address",
)
return address
143 changes: 143 additions & 0 deletions tests/test_merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import pytest
from stellar_sdk import Keypair

import shade
from shade import InvalidRequestError, Merchant, ShadeObject

VALID_ADDRESS = Keypair.random().public_key


def _api_response(**overrides):
"""A representative camelCase backend payload."""
data = {
"id": "clx123",
"merchantId": 42,
"address": VALID_ADDRESS,
"account": "GACCOUNT",
"email": "owner@acme.test",
"firstName": "Ada",
"lastName": "Lovelace",
"businessName": "Acme Payments",
"category": "software",
"description": "We take money.",
"logo": "https://cdn.test/logo.png",
"webhook": "https://acme.test/hooks",
"active": True,
"verified": True,
}
data.update(overrides)
return data


def test_from_dict_maps_camelcase_to_snake_case():
merchant = Merchant.from_dict(_api_response())

assert merchant.id == "clx123"
assert merchant.merchant_id == 42
assert merchant.address == VALID_ADDRESS
assert merchant.first_name == "Ada"
assert merchant.last_name == "Lovelace"
assert merchant.business_name == "Acme Payments"
assert merchant.active is True
assert merchant.verified is True


def test_merchant_id_is_int():
merchant = Merchant.from_dict(_api_response(merchantId=7))
assert isinstance(merchant.merchant_id, int)
assert merchant.merchant_id == 7


def test_merchant_is_exported_from_package():
assert shade.Merchant is Merchant
assert issubclass(Merchant, ShadeObject)


def test_from_dict_ignores_unknown_keys():
merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01", extra="x"))
assert merchant.merchant_id == 42


def test_from_dict_requires_a_mapping():
with pytest.raises(InvalidRequestError):
Merchant.from_dict([("id", "x")]) # type: ignore[arg-type]


def test_invalid_address_raises_on_construction():
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(address="not-a-stellar-key"))
assert exc_info.value.param == "address"


def test_address_wrong_length_is_rejected():
with pytest.raises(InvalidRequestError):
Merchant(
id="x",
merchant_id=1,
address="G" + "A" * 55, # starts with G but too short / bad checksum
active=True,
verified=False,
)


def test_non_integer_merchant_id_raises():
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(merchantId="abc"))
assert exc_info.value.param == "merchant_id"


@pytest.mark.parametrize("field", ["active", "verified"])
@pytest.mark.parametrize("value", ["false", "true", "", 0, 1, None])
def test_non_boolean_flags_are_rejected(field, value):
"""Strings like "false" must not be silently coerced to True."""
with pytest.raises(InvalidRequestError) as exc_info:
Merchant.from_dict(_api_response(**{field: value}))
assert exc_info.value.param == field


def test_boolean_flags_are_preserved():
merchant = Merchant.from_dict(_api_response(active=False, verified=True))
assert merchant.active is False
assert merchant.verified is True


def test_display_name_prefers_business_name():
merchant = Merchant.from_dict(_api_response())
assert merchant.display_name == "Acme Payments"


def test_display_name_falls_back_to_full_name():
merchant = Merchant.from_dict(_api_response(businessName=None))
assert merchant.display_name == "Ada Lovelace"


def test_display_name_trims_missing_last_name():
merchant = Merchant.from_dict(_api_response(businessName=None, lastName=None))
assert merchant.display_name == "Ada"


def test_display_name_falls_back_to_email():
merchant = Merchant.from_dict(
_api_response(businessName=None, firstName=None, lastName=None)
)
assert merchant.display_name == "owner@acme.test"


def test_optional_fields_default_to_none():
merchant = Merchant(
id="x",
merchant_id=1,
address=VALID_ADDRESS,
active=False,
verified=False,
)
assert merchant.account is None
assert merchant.email is None
assert merchant.display_name is None


def test_to_dict_round_trips_to_camelcase():
payload = _api_response()
merchant = Merchant.from_dict(payload)
assert merchant.to_dict() == payload
assert Merchant.from_dict(merchant.to_dict()) == merchant