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:
17
blog/app.py
17
blog/app.py
@@ -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>")
|
||||
|
||||
@@ -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
1
blog/bp/data/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import register as register_data
|
||||
74
blog/bp/data/routes.py
Normal file
74
blog/bp/data/routes.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user