Decouple all cross-app service calls to HTTP endpoints
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:
giles
2026-02-25 03:01:38 +00:00
parent 5dafbdbda9
commit 3b707ec8a0
55 changed files with 1210 additions and 581 deletions

View File

@@ -16,6 +16,7 @@ from bp import (
register_menu_items,
register_snippets,
register_fragments,
register_data,
)
@@ -28,20 +29,25 @@ async def blog_context() -> dict:
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data via service (replaces cross-app HTTP API)
# Cart data via internal data endpoint
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
@@ -98,6 +104,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
app.register_blueprint(register_data())
# --- KV admin endpoints ---
@app.get("/settings/kv/<key>")

View File

@@ -3,3 +3,4 @@ from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data

1
blog/bp/data/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_data

74
blog/bp/data/routes.py Normal file
View File

@@ -0,0 +1,74 @@
"""Blog app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await services.blog.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-slug"] = _post_by_slug
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await services.blog.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-id"] = _post_by_id
# --- posts-by-ids ---
async def _posts_by_ids():
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await services.blog.get_posts_by_ids(g.s, ids)
return [dto_to_dict(p) for p in posts]
_handlers["posts-by-ids"] = _posts_by_ids
# --- search-posts ---
async def _search_posts():
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await services.blog.search_posts(g.s, query, page, per_page)
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
_handlers["search-posts"] = _search_posts
return bp

View File

@@ -193,7 +193,8 @@ def register():
"""Show calendar month view for browsing entries"""
from shared.models.calendars import Calendar
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
from sqlalchemy import select
from datetime import datetime, timezone
import calendar as pycalendar
@@ -228,7 +229,7 @@ def register():
month_name = pycalendar.month_name[month]
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
# Get entries for this month
# Get entries for this month via events data endpoint
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
next_y, next_m = add_months(year, month, +1)
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
@@ -238,10 +239,15 @@ def register():
is_admin = bool(user and getattr(user, "is_admin", False))
session_id = qsession.get("calendar_sid")
month_entries = await services.calendar.visible_entries_for_period(
g.s, calendar_obj.id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
raw_entries = await fetch_data("events", "visible-entries-for-period", params={
"calendar_id": calendar_obj.id,
"period_start": period_start.isoformat(),
"period_end": period_end.isoformat(),
"user_id": user_id,
"is_admin": str(is_admin).lower(),
"session_id": session_id,
}, required=False) or []
month_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
@@ -609,18 +615,24 @@ def register():
return redirect(redirect_url)
async def _fetch_page_markets(post_id):
"""Fetch marketplaces for a page via market data endpoint."""
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import MarketPlaceDTO, dto_from_dict
raw = await fetch_data("market", "marketplaces-for-container",
params={"type": "page", "id": post_id}, required=False) or []
return [dto_from_dict(MarketPlaceDTO, m) for m in raw]
@bp.get("/markets/")
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from shared.services.registry import services
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -634,7 +646,6 @@ def register():
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -651,7 +662,7 @@ def register():
return jsonify({"error": str(e)}), 400
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -665,7 +676,6 @@ def register():
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -676,7 +686,7 @@ def register():
return jsonify({"error": "Market not found"}), 404
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",

View File

@@ -12,7 +12,8 @@ from quart import (
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.browser.app.redis_cacher import cache_page, clear_cache
@@ -92,14 +93,17 @@ def register():
"container_nav_html": container_nav_html,
}
# Page cart badge via service
# Page cart badge via HTTP
post_dict = p_data.get("post") or {}
if post_dict.get("is_page"):
ident = current_cart_identity()
page_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_dict["slug"],
)
summary_params = {"page_slug": post_dict["slug"]}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)

View File

@@ -2,6 +2,9 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
from shared.services.registry import services
@@ -18,10 +21,13 @@ async def toggle_entry_association(
if not post:
return False, "Post not found"
is_associated = await services.calendar.toggle_entry_post(
session, entry_id, "post", post_id,
)
return is_associated, None
try:
result = await call_action("events", "toggle-entry-post", payload={
"entry_id": entry_id, "content_type": "post", "content_id": post_id,
})
return result.get("is_associated", False), None
except ActionError as e:
return False, str(e)
async def get_post_entry_ids(
@@ -32,7 +38,10 @@ async def get_post_entry_ids(
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
return await services.calendar.entry_ids_for_content(session, "post", post_id)
raw = await fetch_data("events", "entry-ids-for-content",
params={"content_type": "post", "content_id": post_id},
required=False) or []
return set(raw)
async def get_associated_entries(
@@ -45,12 +54,14 @@ async def get_associated_entries(
Get paginated associated entries for this post.
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
"""
entries, has_more = await services.calendar.associated_entries(
session, "post", post_id, page,
)
raw = await fetch_data("events", "associated-entries",
params={"content_type": "post", "content_id": post_id, "page": page},
required=False) or {"entries": [], "has_more": False}
entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw.get("entries", [])]
has_more = raw.get("has_more", False)
total_count = len(entries) + (page - 1) * per_page
if has_more:
total_count += 1 # at least one more
total_count += 1
return {
"entries": entries,

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO
from shared.infrastructure.actions import call_action, ActionError
from shared.services.registry import services
@@ -48,8 +49,12 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
result = await call_action("market", "create-marketplace", payload={
"container_type": "page", "container_id": post_id,
"name": name, "slug": slug,
})
return MarketPlaceDTO(**result)
except ActionError as e:
raise MarketError(str(e)) from e
@@ -58,4 +63,10 @@ async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: st
if not post:
return False
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
try:
result = await call_action("market", "soft-delete-marketplace", payload={
"container_type": "page", "container_id": post.id, "slug": market_slug,
})
return result.get("deleted", False)
except ActionError:
return False

View File

@@ -6,23 +6,14 @@ def register_domain_services() -> None:
"""Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()