Decouple all cross-app service calls to HTTP endpoints
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
Replace every direct cross-app services.* call with HTTP-based communication: call_action() for writes, fetch_data() for reads. Each app now registers only its own domain service. Infrastructure: - shared/infrastructure/actions.py — POST client for /internal/actions/ - shared/infrastructure/data_client.py — GET client for /internal/data/ - shared/contracts/dtos.py — dto_to_dict/dto_from_dict serialization Action endpoints (writes): - events: 8 handlers (ticket adjust, claim/confirm, toggle, adopt) - market: 2 handlers (create/soft-delete marketplace) - cart: 1 handler (adopt cart for user) Data endpoints (reads): - blog: 4 (post-by-slug/id, posts-by-ids, search-posts) - events: 10 (pending entries/tickets, entries/tickets for page/order, entry-ids, associated-entries, calendars, visible-entries-for-period) - market: 1 (marketplaces-for-container) - cart: 1 (cart-summary) Service registration cleanup: - blog→blog+federation, events→calendar+federation, market→market+federation, cart→cart only, federation→federation only, account→nothing - Stubs reduced to minimal StubFederationService Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
89
shared/infrastructure/actions.py
Normal file
89
shared/infrastructure/actions.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Internal action client for cross-app write operations.
|
||||
|
||||
Each coop app exposes JSON action endpoints at ``/internal/actions/{name}``.
|
||||
This module provides helpers to call those endpoints so that callers don't
|
||||
need direct access to another app's DB session or service layer.
|
||||
|
||||
Failures raise ``ActionError`` so callers can handle or propagate them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Re-usable async client (created lazily, one per process)
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
# Default request timeout (seconds) — longer than fragments since these are writes
|
||||
_DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
# Header sent on every action request so providers can gate access.
|
||||
ACTION_HEADER = "X-Internal-Action"
|
||||
|
||||
|
||||
class ActionError(Exception):
|
||||
"""Raised when an internal action 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:
|
||||
"""Resolve the Docker-internal base URL for *app_name*."""
|
||||
env_key = f"INTERNAL_URL_{app_name.upper()}"
|
||||
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
|
||||
|
||||
|
||||
async def call_action(
|
||||
app_name: str,
|
||||
action_name: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""POST JSON to ``{INTERNAL_URL_APP}/internal/actions/{action_name}``.
|
||||
|
||||
Returns the parsed JSON response on 2xx.
|
||||
Raises ``ActionError`` on network errors or non-2xx responses.
|
||||
"""
|
||||
base = _internal_url(app_name)
|
||||
url = f"{base}/internal/actions/{action_name}"
|
||||
try:
|
||||
resp = await _get_client().post(
|
||||
url,
|
||||
json=payload or {},
|
||||
headers={ACTION_HEADER: "1"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return resp.json()
|
||||
msg = f"Action {app_name}/{action_name} returned {resp.status_code}"
|
||||
detail = None
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
log.error(msg)
|
||||
raise ActionError(msg, status_code=resp.status_code, detail=detail)
|
||||
except ActionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
msg = f"Action {app_name}/{action_name} failed: {exc}"
|
||||
log.error(msg)
|
||||
raise ActionError(msg) from exc
|
||||
91
shared/infrastructure/data_client.py
Normal file
91
shared/infrastructure/data_client.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Internal data client for cross-app read operations.
|
||||
|
||||
Each coop app exposes JSON data endpoints at ``/internal/data/{query}``.
|
||||
This module provides helpers to fetch that data so that callers don't
|
||||
need direct access to another app's DB session or service layer.
|
||||
|
||||
Same pattern as the fragment client but returns parsed JSON instead of HTML.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Re-usable async client (created lazily, one per process)
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
# Default request timeout (seconds)
|
||||
_DEFAULT_TIMEOUT = 3.0
|
||||
|
||||
# Header sent on every data request so providers can gate access.
|
||||
DATA_HEADER = "X-Internal-Data"
|
||||
|
||||
|
||||
class DataError(Exception):
|
||||
"""Raised when an internal data fetch fails."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
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:
|
||||
"""Resolve the Docker-internal base URL for *app_name*."""
|
||||
env_key = f"INTERNAL_URL_{app_name.upper()}"
|
||||
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
|
||||
|
||||
|
||||
async def fetch_data(
|
||||
app_name: str,
|
||||
query_name: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
required: bool = True,
|
||||
) -> dict | list | None:
|
||||
"""GET JSON from ``{INTERNAL_URL_APP}/internal/data/{query_name}``.
|
||||
|
||||
Returns parsed JSON (dict or list) on success.
|
||||
When *required* is True (default), raises ``DataError`` on failure.
|
||||
When *required* is False, returns None on failure.
|
||||
"""
|
||||
base = _internal_url(app_name)
|
||||
url = f"{base}/internal/data/{query_name}"
|
||||
try:
|
||||
resp = await _get_client().get(
|
||||
url,
|
||||
params=params,
|
||||
headers={DATA_HEADER: "1"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
msg = f"Data {app_name}/{query_name} returned {resp.status_code}"
|
||||
if required:
|
||||
log.error(msg)
|
||||
raise DataError(msg, status_code=resp.status_code)
|
||||
log.warning(msg)
|
||||
return None
|
||||
except DataError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
msg = f"Data {app_name}/{query_name} failed: {exc}"
|
||||
if required:
|
||||
log.error(msg)
|
||||
raise DataError(msg) from exc
|
||||
log.warning(msg)
|
||||
return None
|
||||
Reference in New Issue
Block a user