Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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})"
125 changes: 125 additions & 0 deletions src/shade/merchant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
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}"``); finally ``email``. Each candidate is
trimmed, so a blank or whitespace-only value falls through to the next
one rather than being returned. ``None`` when nothing is available.
"""
business_name = (self.business_name or "").strip()
if business_name:
return business_name
full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
if full_name:
return full_name
return (self.email or "").strip() or None


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
Loading
Loading