Decouple cart/market DBs: denormalize product data, AP internal inbox, OAuth scraper auth
Remove cross-DB relationships (CartItem.product, CartItem.market_place, OrderItem.product) that break with per-service databases. Denormalize product and marketplace fields onto cart_items/order_items at write time. - Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*) for synchronous inter-service writes via HMAC-authenticated POST - Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities - Market app sends AP activities to cart inbox instead of writing CartItem directly - Cart services use denormalized columns instead of cross-DB hydration/joins - Add marketplaces-by-ids data endpoint to market service - Alembic migration adds denormalized columns to cart_items and order_items - Add OAuth device flow auth to market scraper persist_api (artdag client pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
shared/infrastructure/internal_inbox.py
Normal file
49
shared/infrastructure/internal_inbox.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Internal AP inbox dispatch for synchronous inter-service writes.
|
||||
|
||||
Each service can register handlers for (activity_type, object_type) pairs.
|
||||
When an internal AP activity arrives, it is routed to the matching handler.
|
||||
|
||||
This mirrors the federated ``dispatch_inbox_activity()`` pattern but for
|
||||
HMAC-authenticated internal service-to-service calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Registry: (activity_type, object_type) → async handler(session, body) → dict
|
||||
_internal_handlers: dict[tuple[str, str], Callable[[AsyncSession, dict], Awaitable[dict]]] = {}
|
||||
|
||||
|
||||
def register_internal_handler(
|
||||
activity_type: str,
|
||||
object_type: str,
|
||||
handler: Callable[[AsyncSession, dict], Awaitable[dict]],
|
||||
) -> None:
|
||||
"""Register a handler for an internal AP activity type + object type pair."""
|
||||
key = (activity_type, object_type)
|
||||
if key in _internal_handlers:
|
||||
log.warning("Overwriting internal handler for %s", key)
|
||||
_internal_handlers[key] = handler
|
||||
|
||||
|
||||
async def dispatch_internal_activity(session: AsyncSession, body: dict) -> dict:
|
||||
"""Route an internal AP activity to the correct handler.
|
||||
|
||||
Returns the handler result dict. Raises ValueError for unknown types.
|
||||
"""
|
||||
activity_type = body.get("type", "")
|
||||
obj = body.get("object") or {}
|
||||
object_type = obj.get("type", "") if isinstance(obj, dict) else ""
|
||||
|
||||
key = (activity_type, object_type)
|
||||
handler = _internal_handlers.get(key)
|
||||
if handler is None:
|
||||
raise ValueError(f"No internal handler for {key}")
|
||||
|
||||
log.info("Dispatching internal activity %s", key)
|
||||
return await handler(session, body)
|
||||
84
shared/infrastructure/internal_inbox_client.py
Normal file
84
shared/infrastructure/internal_inbox_client.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Client for sending internal AP activities to other services.
|
||||
|
||||
Replaces ``call_action`` for AP-shaped inter-service writes.
|
||||
Uses the same HMAC authentication as actions/data clients.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from shared.infrastructure.internal_auth import sign_internal_headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
_DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
INBOX_HEADER = "X-Internal-Inbox"
|
||||
|
||||
|
||||
class InboxError(Exception):
|
||||
"""Raised when an internal inbox call fails."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500, detail: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(_DEFAULT_TIMEOUT),
|
||||
follow_redirects=False,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _internal_url(app_name: str) -> str:
|
||||
env_key = f"INTERNAL_URL_{app_name.upper()}"
|
||||
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
|
||||
|
||||
|
||||
async def send_internal_activity(
|
||||
app_name: str,
|
||||
activity: dict,
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""POST an AP activity to the target service's /internal/inbox.
|
||||
|
||||
Returns the parsed JSON response on 2xx.
|
||||
Raises ``InboxError`` on network errors or non-2xx responses.
|
||||
"""
|
||||
base = _internal_url(app_name)
|
||||
url = f"{base}/internal/inbox"
|
||||
try:
|
||||
headers = {INBOX_HEADER: "1", **sign_internal_headers(app_name)}
|
||||
resp = await _get_client().post(
|
||||
url,
|
||||
json=activity,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return resp.json()
|
||||
msg = f"Inbox {app_name} returned {resp.status_code}"
|
||||
detail = None
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
log.error(msg)
|
||||
raise InboxError(msg, status_code=resp.status_code, detail=detail)
|
||||
except InboxError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
msg = f"Inbox {app_name} failed: {exc}"
|
||||
log.error(msg)
|
||||
raise InboxError(msg) from exc
|
||||
@@ -410,19 +410,18 @@ class CartItem(Base):
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Cross-domain relationships — explicit join, viewonly (no FK constraint)
|
||||
market_place: Mapped["MarketPlace | None"] = relationship(
|
||||
"MarketPlace",
|
||||
primaryjoin="CartItem.market_place_id == MarketPlace.id",
|
||||
foreign_keys="[CartItem.market_place_id]",
|
||||
viewonly=True,
|
||||
)
|
||||
product: Mapped["Product"] = relationship(
|
||||
"Product",
|
||||
primaryjoin="CartItem.product_id == Product.id",
|
||||
foreign_keys="[CartItem.product_id]",
|
||||
viewonly=True,
|
||||
)
|
||||
# Denormalized product data (snapshotted at write time)
|
||||
product_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
product_slug: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
product_image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
product_brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
product_regular_price: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
product_special_price: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
product_price_currency: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
# Denormalized marketplace data (snapshotted at write time)
|
||||
market_place_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
market_place_container_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_cart_items_user_product", "user_id", "product_id"),
|
||||
|
||||
@@ -87,6 +87,8 @@ class OrderItem(Base):
|
||||
nullable=False,
|
||||
)
|
||||
product_title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
product_slug: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
product_image: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
@@ -102,12 +104,3 @@ class OrderItem(Base):
|
||||
"Order",
|
||||
back_populates="items",
|
||||
)
|
||||
|
||||
# Cross-domain relationship — explicit join, viewonly (no FK constraint)
|
||||
product: Mapped["Product"] = relationship(
|
||||
"Product",
|
||||
primaryjoin="OrderItem.product_id == Product.id",
|
||||
foreign_keys="[OrderItem.product_id]",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user