diff --git a/account/actions.sx b/account/actions.sx new file mode 100644 index 0000000..8d447f1 --- /dev/null +++ b/account/actions.sx @@ -0,0 +1,4 @@ +;; Account service — inter-service action endpoints +;; +;; ghost-sync-member and ghost-push-member use local service imports — +;; remain as Python fallbacks. diff --git a/account/bp/actions/routes.py b/account/bp/actions/routes.py index e0a73a8..713cb1e 100644 --- a/account/bp/actions/routes.py +++ b/account/bp/actions/routes.py @@ -1,63 +1,33 @@ """Account app action endpoints. -Exposes write operations at ``/internal/actions/`` for -cross-app callers (blog webhooks) via the internal action client. +All actions remain as Python fallbacks (local service imports). """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint, g, request -from shared.infrastructure.actions import ACTION_HEADER +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + bp, _handlers = create_action_blueprint("account") - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- ghost-sync-member --- async def _ghost_sync_member(): - """Sync a single Ghost member into db_account.""" data = await request.get_json() ghost_id = data.get("ghost_id") if not ghost_id: return {"error": "ghost_id required"}, 400 - from services.ghost_membership import sync_single_member await sync_single_member(g.s, ghost_id) return {"ok": True} _handlers["ghost-sync-member"] = _ghost_sync_member - # --- ghost-push-member --- async def _ghost_push_member(): - """Push a local user's membership data to Ghost.""" data = await request.get_json() user_id = data.get("user_id") if not user_id: return {"error": "user_id required"}, 400 - from services.ghost_membership import sync_member_to_ghost result_id = await sync_member_to_ghost(g.s, int(user_id)) return {"ok": True, "ghost_id": result_id} diff --git a/account/bp/data/routes.py b/account/bp/data/routes.py index d104121..4fc477f 100644 --- a/account/bp/data/routes.py +++ b/account/bp/data/routes.py @@ -1,67 +1,14 @@ """Account app data endpoints. -Exposes read-only JSON queries at ``/internal/data/`` for -cross-app callers via the internal data client. +All queries are defined in ``account/queries.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER -from sqlalchemy import select -from shared.models import User +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- user-by-email --- - async def _user_by_email(): - """Return user_id for a given email address.""" - email = request.args.get("email", "").strip().lower() - if not email: - return None - result = await g.s.execute( - select(User.id).where(User.email.ilike(email)) - ) - row = result.first() - if not row: - return None - return {"user_id": row[0]} - - _handlers["user-by-email"] = _user_by_email - - # --- newsletters --- - async def _newsletters(): - """Return all Ghost newsletters (for blog post editor).""" - from shared.models.ghost_membership_entities import GhostNewsletter - result = await g.s.execute( - select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug) - .order_by(GhostNewsletter.name) - ) - return [ - {"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]} - for row in result.all() - ] - - _handlers["newsletters"] = _newsletters - + bp, _handlers = create_data_blueprint("account") return bp diff --git a/account/queries.sx b/account/queries.sx new file mode 100644 index 0000000..a3e5af5 --- /dev/null +++ b/account/queries.sx @@ -0,0 +1,9 @@ +;; Account service — inter-service data queries + +(defquery user-by-email (&key email) + "Return user_id for a given email address." + (service "account" "user-by-email" :email email)) + +(defquery newsletters () + "Return all Ghost newsletters." + (service "account" "newsletters")) diff --git a/account/services/__init__.py b/account/services/__init__.py index ce716c7..0915b0a 100644 --- a/account/services/__init__.py +++ b/account/services/__init__.py @@ -7,3 +7,6 @@ def register_domain_services() -> None: from shared.services.registry import services from .account_page import AccountPageService services.register("account_page", AccountPageService()) + + from shared.services.account_impl import SqlAccountDataService + services.register("account", SqlAccountDataService()) diff --git a/blog/actions.sx b/blog/actions.sx new file mode 100644 index 0000000..7e18067 --- /dev/null +++ b/blog/actions.sx @@ -0,0 +1,12 @@ +;; Blog service — inter-service action endpoints + +(defaction update-page-config (&key container-type container-id + features sumup-merchant-code + sumup-checkout-prefix sumup-api-key) + "Create or update a PageConfig with features and SumUp settings." + (service "page-config" "update" + :container-type container-type :container-id container-id + :features features + :sumup-merchant-code sumup-merchant-code + :sumup-checkout-prefix sumup-checkout-prefix + :sumup-api-key sumup-api-key)) diff --git a/blog/bp/actions/routes.py b/blog/bp/actions/routes.py index 2013bca..76d79cc 100644 --- a/blog/bp/actions/routes.py +++ b/blog/bp/actions/routes.py @@ -1,96 +1,14 @@ """Blog app action endpoints. -Exposes write operations at ``/internal/actions/`` for -cross-app callers via the internal action client. +All actions are defined in ``blog/actions.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.actions import ACTION_HEADER +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") - - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - result = await handler() - return jsonify(result or {"ok": True}) - - # --- update-page-config --- - async def _update_page_config(): - """Create or update a PageConfig (page_configs now lives in db_blog).""" - from shared.models.page_config import PageConfig - from sqlalchemy import select - from sqlalchemy.orm.attributes import flag_modified - - data = await request.get_json(force=True) - container_type = data.get("container_type", "page") - container_id = data.get("container_id") - if container_id is None: - return {"error": "container_id required"}, 400 - - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == container_type, - PageConfig.container_id == container_id, - ) - )).scalar_one_or_none() - - if pc is None: - pc = PageConfig( - container_type=container_type, - container_id=container_id, - features=data.get("features", {}), - ) - g.s.add(pc) - await g.s.flush() - - if "features" in data: - features = dict(pc.features or {}) - for key, val in data["features"].items(): - if isinstance(val, bool): - features[key] = val - elif val in ("true", "1", "on"): - features[key] = True - elif val in ("false", "0", "off", None): - features[key] = False - pc.features = features - flag_modified(pc, "features") - - if "sumup_merchant_code" in data: - pc.sumup_merchant_code = data["sumup_merchant_code"] or None - if "sumup_checkout_prefix" in data: - pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None - if "sumup_api_key" in data: - pc.sumup_api_key = data["sumup_api_key"] or None - - await g.s.flush() - - return { - "id": pc.id, - "container_type": pc.container_type, - "container_id": pc.container_id, - "features": pc.features or {}, - "sumup_merchant_code": pc.sumup_merchant_code, - "sumup_checkout_prefix": pc.sumup_checkout_prefix, - "sumup_configured": bool(pc.sumup_api_key), - } - - _handlers["update-page-config"] = _update_page_config - + bp, _handlers = create_action_blueprint("blog") return bp diff --git a/blog/bp/data/routes.py b/blog/bp/data/routes.py index b76f2b1..dedbf65 100644 --- a/blog/bp/data/routes.py +++ b/blog/bp/data/routes.py @@ -1,185 +1,14 @@ """Blog app data endpoints. -Exposes read-only JSON queries at ``/internal/data/`` for -cross-app callers via the internal data client. +All queries are defined in ``blog/queries.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER -from shared.contracts.dtos import dto_to_dict -from services import blog_service +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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 blog_service.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 blog_service.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 blog_service.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 blog_service.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 - - # --- page-config-ensure --- - async def _page_config_ensure(): - """Get or create a PageConfig for a container_type + container_id.""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - container_type = request.args.get("container_type", "page") - container_id = request.args.get("container_id", type=int) - if container_id is None: - return {"error": "container_id required"}, 400 - - row = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == container_type, - PageConfig.container_id == container_id, - ) - )).scalar_one_or_none() - - if row is None: - row = PageConfig( - container_type=container_type, - container_id=container_id, - features={}, - ) - g.s.add(row) - await g.s.flush() - - return { - "id": row.id, - "container_type": row.container_type, - "container_id": row.container_id, - } - - _handlers["page-config-ensure"] = _page_config_ensure - - # --- page-config --- - async def _page_config(): - """Return a single PageConfig by container_type + container_id.""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - ct = request.args.get("container_type", "page") - cid = request.args.get("container_id", type=int) - if cid is None: - return None - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == ct, - PageConfig.container_id == cid, - ) - )).scalar_one_or_none() - if not pc: - return None - return _page_config_dict(pc) - - _handlers["page-config"] = _page_config - - # --- page-config-by-id --- - async def _page_config_by_id(): - """Return a single PageConfig by its primary key.""" - from shared.models.page_config import PageConfig - - pc_id = request.args.get("id", type=int) - if pc_id is None: - return None - pc = await g.s.get(PageConfig, pc_id) - if not pc: - return None - return _page_config_dict(pc) - - _handlers["page-config-by-id"] = _page_config_by_id - - # --- page-configs-batch --- - async def _page_configs_batch(): - """Return PageConfigs for multiple container_ids (comma-separated).""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - ct = request.args.get("container_type", "page") - ids_raw = request.args.get("ids", "") - if not ids_raw: - return [] - ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()] - if not ids: - return [] - result = await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == ct, - PageConfig.container_id.in_(ids), - ) - ) - return [_page_config_dict(pc) for pc in result.scalars().all()] - - _handlers["page-configs-batch"] = _page_configs_batch - + bp, _handlers = create_data_blueprint("blog") return bp - - -def _page_config_dict(pc) -> dict: - """Serialize PageConfig to a JSON-safe dict.""" - return { - "id": pc.id, - "container_type": pc.container_type, - "container_id": pc.container_id, - "features": pc.features or {}, - "sumup_merchant_code": pc.sumup_merchant_code, - "sumup_api_key": pc.sumup_api_key, - "sumup_checkout_prefix": pc.sumup_checkout_prefix, - } diff --git a/blog/queries.sx b/blog/queries.sx new file mode 100644 index 0000000..e03c4a1 --- /dev/null +++ b/blog/queries.sx @@ -0,0 +1,38 @@ +;; Blog service — inter-service data queries + +(defquery post-by-slug (&key slug) + "Fetch a single blog post by its URL slug." + (service "blog" "get-post-by-slug" :slug slug)) + +(defquery post-by-id (&key id) + "Fetch a single blog post by its primary key." + (service "blog" "get-post-by-id" :id id)) + +(defquery posts-by-ids (&key ids) + "Fetch multiple blog posts by comma-separated IDs." + (service "blog" "get-posts-by-ids" :ids (split-ids ids))) + +(defquery search-posts (&key query page per-page) + "Search blog posts by text query, paginated." + (let ((result (service "blog" "search-posts" + :query query :page page :per-page per-page))) + {"posts" (nth result 0) "total" (nth result 1)})) + +(defquery page-config-ensure (&key container-type container-id) + "Get or create a PageConfig for a container." + (service "page-config" "ensure" + :container-type container-type :container-id container-id)) + +(defquery page-config (&key container-type container-id) + "Return a single PageConfig by container type + id." + (service "page-config" "get-by-container" + :container-type container-type :container-id container-id)) + +(defquery page-config-by-id (&key id) + "Return a single PageConfig by primary key." + (service "page-config" "get-by-id" :id id)) + +(defquery page-configs-batch (&key container-type ids) + "Return PageConfigs for multiple container IDs (comma-separated)." + (service "page-config" "get-batch" + :container-type container-type :ids (split-ids ids))) diff --git a/blog/services/__init__.py b/blog/services/__init__.py index d00fe83..35635f2 100644 --- a/blog/services/__init__.py +++ b/blog/services/__init__.py @@ -71,8 +71,13 @@ def register_domain_services() -> None: Blog owns: Post, Tag, Author, PostAuthor, PostTag. Cross-app calls go over HTTP via call_action() / fetch_data(). """ - # Federation needed for AP shared infrastructure (activitypub blueprint) from shared.services.registry import services + services.register("blog", blog_service) + + from shared.services.page_config_impl import SqlPageConfigService + services.register("page_config", SqlPageConfigService()) + + # Federation needed for AP shared infrastructure (activitypub blueprint) if not services.has("federation"): from shared.services.federation_impl import SqlFederationService services.federation = SqlFederationService() diff --git a/cart/actions.sx b/cart/actions.sx new file mode 100644 index 0000000..4c58cee --- /dev/null +++ b/cart/actions.sx @@ -0,0 +1,10 @@ +;; Cart service — inter-service action endpoints + +(defaction adopt-cart-for-user (&key user-id session-id) + "Transfer anonymous cart items to a logged-in user." + (do + (service "cart" "adopt-cart-for-user" + :user-id user-id :session-id session-id) + {"ok" true})) + +;; clear-cart-for-order: remains as Python fallback (complex object construction) diff --git a/cart/bp/actions/routes.py b/cart/bp/actions/routes.py index 8401a9f..2d7f777 100644 --- a/cart/bp/actions/routes.py +++ b/cart/bp/actions/routes.py @@ -1,64 +1,26 @@ """Cart app action endpoints. -Exposes write operations at ``/internal/actions/`` for -cross-app callers (login handler) via the internal action client. +adopt-cart-for-user is defined in ``cart/actions.sx``. +clear-cart-for-order remains as a Python fallback (complex object construction). """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint, g, request -from shared.infrastructure.actions import ACTION_HEADER -from shared.services.registry import services +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + bp, _handlers = create_action_blueprint("cart") - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- adopt-cart-for-user --- - async def _adopt_cart(): - data = await request.get_json() - await services.cart.adopt_cart_for_user( - g.s, data["user_id"], data["session_id"], - ) - return {"ok": True} - - _handlers["adopt-cart-for-user"] = _adopt_cart - - # --- clear-cart-for-order --- async def _clear_cart_for_order(): - """Soft-delete cart items after an order is paid. Called by orders service.""" from bp.cart.services.clear_cart_for_order import clear_cart_for_order - from shared.models.order import Order data = await request.get_json() user_id = data.get("user_id") session_id = data.get("session_id") page_post_id = data.get("page_post_id") - # Build a minimal order-like object with the fields clear_cart_for_order needs order = type("_Order", (), { "user_id": user_id, "session_id": session_id, diff --git a/cart/bp/data/routes.py b/cart/bp/data/routes.py index dbaa45d..53affb0 100644 --- a/cart/bp/data/routes.py +++ b/cart/bp/data/routes.py @@ -1,79 +1,14 @@ """Cart app data endpoints. -Exposes read-only JSON queries at ``/internal/data/`` for -cross-app callers via the internal data client. +All queries are defined in ``cart/queries.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER -from shared.contracts.dtos import dto_to_dict -from shared.services.registry import services +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- cart-summary --- - async def _cart_summary(): - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - page_slug = request.args.get("page_slug") - summary = await services.cart.cart_summary( - g.s, user_id=user_id, session_id=session_id, page_slug=page_slug, - ) - return dto_to_dict(summary) - - _handlers["cart-summary"] = _cart_summary - - # --- cart-items (product slugs + quantities for template rendering) --- - async def _cart_items(): - from sqlalchemy import select - from shared.models.market import CartItem - - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - - filters = [CartItem.deleted_at.is_(None)] - if user_id is not None: - filters.append(CartItem.user_id == user_id) - elif session_id is not None: - filters.append(CartItem.session_id == session_id) - else: - return [] - - result = await g.s.execute( - select(CartItem).where(*filters) - ) - items = result.scalars().all() - return [ - { - "product_id": item.product_id, - "product_slug": item.product_slug, - "quantity": item.quantity, - } - for item in items - ] - - _handlers["cart-items"] = _cart_items - + bp, _handlers = create_data_blueprint("cart") return bp diff --git a/cart/queries.sx b/cart/queries.sx new file mode 100644 index 0000000..99c136f --- /dev/null +++ b/cart/queries.sx @@ -0,0 +1,11 @@ +;; Cart service — inter-service data queries + +(defquery cart-summary (&key user-id session-id page-slug) + "Cart summary for a user or session, optionally filtered by page." + (service "cart" "cart-summary" + :user-id user-id :session-id session-id :page-slug page-slug)) + +(defquery cart-items (&key user-id session-id) + "Product slugs and quantities in the cart." + (service "cart-data" "cart-items" + :user-id user-id :session-id session-id)) diff --git a/cart/services/__init__.py b/cart/services/__init__.py index e1251b7..3dfd315 100644 --- a/cart/services/__init__.py +++ b/cart/services/__init__.py @@ -13,5 +13,8 @@ def register_domain_services() -> None: services.cart = SqlCartService() + from shared.services.cart_items_impl import SqlCartItemsService + services.register("cart_data", SqlCartItemsService()) + from .cart_page import CartPageService services.register("cart_page", CartPageService()) diff --git a/events/actions.sx b/events/actions.sx new file mode 100644 index 0000000..bf53408 --- /dev/null +++ b/events/actions.sx @@ -0,0 +1,65 @@ +;; Events service — inter-service action endpoints +;; +;; Each defaction replaces a Python handler in bp/actions/routes.py. +;; The (service ...) primitive calls the registered CalendarService method +;; with g.s (async session) + keyword args. + +(defaction adjust-ticket-quantity (&key entry-id count user-id session-id ticket-type-id) + "Add or remove tickets for a calendar entry." + (do + (service "calendar" "adjust-ticket-quantity" + :entry-id entry-id :count count + :user-id user-id :session-id session-id + :ticket-type-id ticket-type-id) + {"ok" true})) + +(defaction claim-entries-for-order (&key order-id user-id session-id page-post-id) + "Claim pending calendar entries for an order." + (do + (service "calendar" "claim-entries-for-order" + :order-id order-id :user-id user-id + :session-id session-id :page-post-id page-post-id) + {"ok" true})) + +(defaction claim-tickets-for-order (&key order-id user-id session-id page-post-id) + "Claim pending tickets for an order." + (do + (service "calendar" "claim-tickets-for-order" + :order-id order-id :user-id user-id + :session-id session-id :page-post-id page-post-id) + {"ok" true})) + +(defaction confirm-entries-for-order (&key order-id user-id session-id) + "Confirm calendar entries after payment." + (do + (service "calendar" "confirm-entries-for-order" + :order-id order-id :user-id user-id :session-id session-id) + {"ok" true})) + +(defaction confirm-tickets-for-order (&key order-id) + "Confirm tickets after payment." + (do + (service "calendar" "confirm-tickets-for-order" :order-id order-id) + {"ok" true})) + +(defaction toggle-entry-post (&key entry-id content-type content-id) + "Toggle association between a calendar entry and a content item." + (let ((is-associated (service "calendar" "toggle-entry-post" + :entry-id entry-id + :content-type content-type + :content-id content-id))) + {"is_associated" is-associated})) + +(defaction adopt-entries-for-user (&key user-id session-id) + "Transfer anonymous calendar entries to a logged-in user." + (do + (service "calendar" "adopt-entries-for-user" + :user-id user-id :session-id session-id) + {"ok" true})) + +(defaction adopt-tickets-for-user (&key user-id session-id) + "Transfer anonymous tickets to a logged-in user." + (do + (service "calendar" "adopt-tickets-for-user" + :user-id user-id :session-id session-id) + {"ok" true})) diff --git a/events/bp/actions/routes.py b/events/bp/actions/routes.py index 8f37a34..f98095c 100644 --- a/events/bp/actions/routes.py +++ b/events/bp/actions/routes.py @@ -1,139 +1,15 @@ """Events app action endpoints. -Exposes write operations at ``/internal/actions/`` for -cross-app callers (cart, blog) via the internal action client. +All actions are defined declaratively in ``events/actions.sx`` and +dispatched via the sx query registry. No Python fallbacks needed. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from shared.infrastructure.query_blueprint import create_action_blueprint -from shared.infrastructure.actions import ACTION_HEADER -from shared.services.registry import services +from quart import Blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") - - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- adjust-ticket-quantity --- - async def _adjust_ticket_quantity(): - data = await request.get_json() - await services.calendar.adjust_ticket_quantity( - g.s, - data["entry_id"], - data["count"], - user_id=data.get("user_id"), - session_id=data.get("session_id"), - ticket_type_id=data.get("ticket_type_id"), - ) - return {"ok": True} - - _handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity - - # --- claim-entries-for-order --- - async def _claim_entries(): - data = await request.get_json() - await services.calendar.claim_entries_for_order( - g.s, - data["order_id"], - data.get("user_id"), - data.get("session_id"), - data.get("page_post_id"), - ) - return {"ok": True} - - _handlers["claim-entries-for-order"] = _claim_entries - - # --- claim-tickets-for-order --- - async def _claim_tickets(): - data = await request.get_json() - await services.calendar.claim_tickets_for_order( - g.s, - data["order_id"], - data.get("user_id"), - data.get("session_id"), - data.get("page_post_id"), - ) - return {"ok": True} - - _handlers["claim-tickets-for-order"] = _claim_tickets - - # --- confirm-entries-for-order --- - async def _confirm_entries(): - data = await request.get_json() - await services.calendar.confirm_entries_for_order( - g.s, - data["order_id"], - data.get("user_id"), - data.get("session_id"), - ) - return {"ok": True} - - _handlers["confirm-entries-for-order"] = _confirm_entries - - # --- confirm-tickets-for-order --- - async def _confirm_tickets(): - data = await request.get_json() - await services.calendar.confirm_tickets_for_order( - g.s, data["order_id"], - ) - return {"ok": True} - - _handlers["confirm-tickets-for-order"] = _confirm_tickets - - # --- toggle-entry-post --- - async def _toggle_entry_post(): - data = await request.get_json() - is_associated = await services.calendar.toggle_entry_post( - g.s, - data["entry_id"], - data["content_type"], - data["content_id"], - ) - return {"is_associated": is_associated} - - _handlers["toggle-entry-post"] = _toggle_entry_post - - # --- adopt-entries-for-user --- - async def _adopt_entries(): - data = await request.get_json() - await services.calendar.adopt_entries_for_user( - g.s, data["user_id"], data["session_id"], - ) - return {"ok": True} - - _handlers["adopt-entries-for-user"] = _adopt_entries - - # --- adopt-tickets-for-user --- - async def _adopt_tickets(): - data = await request.get_json() - await services.calendar.adopt_tickets_for_user( - g.s, data["user_id"], data["session_id"], - ) - return {"ok": True} - - _handlers["adopt-tickets-for-user"] = _adopt_tickets - + bp, _handlers = create_action_blueprint("events") return bp diff --git a/events/bp/data/routes.py b/events/bp/data/routes.py index cb3f7f2..fb0928e 100644 --- a/events/bp/data/routes.py +++ b/events/bp/data/routes.py @@ -1,148 +1,14 @@ """Events app data endpoints. -Exposes read-only JSON queries at ``/internal/data/`` for -cross-app callers via the internal data client. +All queries are defined in ``events/queries.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER -from shared.contracts.dtos import dto_to_dict -from shared.services.registry import services +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- pending-entries --- - async def _pending_entries(): - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - entries = await services.calendar.pending_entries( - g.s, user_id=user_id, session_id=session_id, - ) - return [dto_to_dict(e) for e in entries] - - _handlers["pending-entries"] = _pending_entries - - # --- pending-tickets --- - async def _pending_tickets(): - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - tickets = await services.calendar.pending_tickets( - g.s, user_id=user_id, session_id=session_id, - ) - return [dto_to_dict(t) for t in tickets] - - _handlers["pending-tickets"] = _pending_tickets - - # --- entries-for-page --- - async def _entries_for_page(): - page_id = request.args.get("page_id", type=int) - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - entries = await services.calendar.entries_for_page( - g.s, page_id, user_id=user_id, session_id=session_id, - ) - return [dto_to_dict(e) for e in entries] - - _handlers["entries-for-page"] = _entries_for_page - - # --- tickets-for-page --- - async def _tickets_for_page(): - page_id = request.args.get("page_id", type=int) - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - tickets = await services.calendar.tickets_for_page( - g.s, page_id, user_id=user_id, session_id=session_id, - ) - return [dto_to_dict(t) for t in tickets] - - _handlers["tickets-for-page"] = _tickets_for_page - - # --- entries-for-order --- - async def _entries_for_order(): - order_id = request.args.get("order_id", type=int) - entries = await services.calendar.get_entries_for_order(g.s, order_id) - return [dto_to_dict(e) for e in entries] - - _handlers["entries-for-order"] = _entries_for_order - - # --- tickets-for-order --- - async def _tickets_for_order(): - order_id = request.args.get("order_id", type=int) - tickets = await services.calendar.get_tickets_for_order(g.s, order_id) - return [dto_to_dict(t) for t in tickets] - - _handlers["tickets-for-order"] = _tickets_for_order - - # --- entry-ids-for-content --- - async def _entry_ids_for_content(): - content_type = request.args.get("content_type", "") - content_id = request.args.get("content_id", type=int) - ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id) - return list(ids) - - _handlers["entry-ids-for-content"] = _entry_ids_for_content - - # --- associated-entries --- - async def _associated_entries(): - content_type = request.args.get("content_type", "") - content_id = request.args.get("content_id", type=int) - page = request.args.get("page", 1, type=int) - entries, has_more = await services.calendar.associated_entries( - g.s, content_type, content_id, page, - ) - return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more} - - _handlers["associated-entries"] = _associated_entries - - # --- calendars-for-container --- - async def _calendars_for_container(): - container_type = request.args.get("type", "") - container_id = request.args.get("id", type=int) - calendars = await services.calendar.calendars_for_container( - g.s, container_type, container_id, - ) - return [dto_to_dict(c) for c in calendars] - - _handlers["calendars-for-container"] = _calendars_for_container - - # --- visible-entries-for-period --- - async def _visible_entries_for_period(): - from datetime import datetime - calendar_id = request.args.get("calendar_id", type=int) - period_start = datetime.fromisoformat(request.args.get("period_start", "")) - period_end = datetime.fromisoformat(request.args.get("period_end", "")) - user_id = request.args.get("user_id", type=int) - session_id = request.args.get("session_id") - # is_admin determined server-side, never from client params - is_admin = False - entries = await services.calendar.visible_entries_for_period( - g.s, calendar_id, period_start, period_end, - user_id=user_id, is_admin=is_admin, session_id=session_id, - ) - return [dto_to_dict(e) for e in entries] - - _handlers["visible-entries-for-period"] = _visible_entries_for_period - + bp, _handlers = create_data_blueprint("events") return bp diff --git a/events/queries.sx b/events/queries.sx new file mode 100644 index 0000000..ec83207 --- /dev/null +++ b/events/queries.sx @@ -0,0 +1,57 @@ +;; Events service — inter-service data queries +;; +;; Each defquery replaces a Python handler in bp/data/routes.py. +;; The (service ...) primitive calls the registered CalendarService method +;; with g.s (async session) + keyword args, and auto-converts DTOs to dicts. + +(defquery pending-entries (&key user-id session-id) + "Calendar entries in pending state for a user or session." + (service "calendar" "pending-entries" + :user-id user-id :session-id session-id)) + +(defquery pending-tickets (&key user-id session-id) + "Tickets in pending state for a user or session." + (service "calendar" "pending-tickets" + :user-id user-id :session-id session-id)) + +(defquery entries-for-page (&key page-id user-id session-id) + "Calendar entries for a specific page." + (service "calendar" "entries-for-page" + :page-id page-id :user-id user-id :session-id session-id)) + +(defquery tickets-for-page (&key page-id user-id session-id) + "Tickets for a specific page." + (service "calendar" "tickets-for-page" + :page-id page-id :user-id user-id :session-id session-id)) + +(defquery entries-for-order (&key order-id) + "Calendar entries claimed by an order." + (service "calendar" "get-entries-for-order" :order-id order-id)) + +(defquery tickets-for-order (&key order-id) + "Tickets claimed by an order." + (service "calendar" "get-tickets-for-order" :order-id order-id)) + +(defquery entry-ids-for-content (&key content-type content-id) + "Entry IDs associated with a content item." + (service "calendar" "entry-ids-for-content" + :content-type content-type :content-id content-id)) + +(defquery associated-entries (&key content-type content-id page) + "Entries associated with content, paginated." + (let ((result (service "calendar" "associated-entries" + :content-type content-type :content-id content-id :page page))) + {"entries" (nth result 0) "has_more" (nth result 1)})) + +(defquery calendars-for-container (&key type id) + "Calendars attached to a container (page, marketplace, etc)." + (service "calendar" "calendars-for-container" + :container-type type :container-id id)) + +(defquery visible-entries-for-period (&key calendar-id period-start period-end user-id session-id) + "Visible entries within a date range for a calendar." + (service "calendar" "visible-entries-for-period" + :calendar-id calendar-id + :period-start (parse-datetime period-start) + :period-end (parse-datetime period-end) + :user-id user-id :is-admin false :session-id session-id)) diff --git a/likes/actions.sx b/likes/actions.sx new file mode 100644 index 0000000..ac9556b --- /dev/null +++ b/likes/actions.sx @@ -0,0 +1,8 @@ +;; Likes service — inter-service action endpoints + +(defaction toggle (&key user-id target-type target-slug target-id) + "Toggle a like on a content item. Returns whether now liked." + (let ((liked (service "likes" "toggle" + :user-id user-id :target-type target-type + :target-slug target-slug :target-id target-id))) + {"liked" liked})) diff --git a/likes/bp/actions/routes.py b/likes/bp/actions/routes.py index ab05051..d34eca0 100644 --- a/likes/bp/actions/routes.py +++ b/likes/bp/actions/routes.py @@ -1,81 +1,14 @@ -"""Likes app action endpoints.""" +"""Likes app action endpoints. + +All actions are defined in ``likes/actions.sx``. +""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.actions import ACTION_HEADER +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") - - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- toggle --- - async def _toggle(): - """Toggle a like. Returns {"liked": bool}.""" - from sqlalchemy import select, update, func - from likes.models.like import Like - - data = await request.get_json(force=True) - user_id = data["user_id"] - target_type = data["target_type"] - target_slug = data.get("target_slug") - target_id = data.get("target_id") - - filters = [ - Like.user_id == user_id, - Like.target_type == target_type, - Like.deleted_at.is_(None), - ] - if target_slug is not None: - filters.append(Like.target_slug == target_slug) - elif target_id is not None: - filters.append(Like.target_id == target_id) - else: - return {"error": "target_slug or target_id required"}, 400 - - existing = await g.s.scalar(select(Like).where(*filters)) - - if existing: - # Unlike: soft delete - await g.s.execute( - update(Like).where(Like.id == existing.id).values(deleted_at=func.now()) - ) - return {"liked": False} - else: - # Like: insert new - new_like = Like( - user_id=user_id, - target_type=target_type, - target_slug=target_slug, - target_id=target_id, - ) - g.s.add(new_like) - await g.s.flush() - return {"liked": True} - - _handlers["toggle"] = _toggle - + bp, _handlers = create_action_blueprint("likes") return bp diff --git a/likes/bp/data/routes.py b/likes/bp/data/routes.py index 93e68bf..605b531 100644 --- a/likes/bp/data/routes.py +++ b/likes/bp/data/routes.py @@ -1,109 +1,14 @@ -"""Likes app data endpoints.""" +"""Likes app data endpoints. + +All queries are defined in ``likes/queries.sx``. +""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- is-liked --- - async def _is_liked(): - """Check if a user has liked a specific target.""" - from sqlalchemy import select - from models.like import Like - - user_id = request.args.get("user_id", type=int) - target_type = request.args.get("target_type", "") - target_slug = request.args.get("target_slug") - target_id = request.args.get("target_id", type=int) - - if not user_id or not target_type: - return {"liked": False} - - filters = [ - Like.user_id == user_id, - Like.target_type == target_type, - Like.deleted_at.is_(None), - ] - if target_slug is not None: - filters.append(Like.target_slug == target_slug) - elif target_id is not None: - filters.append(Like.target_id == target_id) - else: - return {"liked": False} - - row = await g.s.scalar(select(Like.id).where(*filters)) - return {"liked": row is not None} - - _handlers["is-liked"] = _is_liked - - # --- liked-slugs --- - async def _liked_slugs(): - """Return all liked target_slugs for a user + target_type.""" - from sqlalchemy import select - from models.like import Like - - user_id = request.args.get("user_id", type=int) - target_type = request.args.get("target_type", "") - - if not user_id or not target_type: - return [] - - result = await g.s.execute( - select(Like.target_slug).where( - Like.user_id == user_id, - Like.target_type == target_type, - Like.target_slug.isnot(None), - Like.deleted_at.is_(None), - ) - ) - return list(result.scalars().all()) - - _handlers["liked-slugs"] = _liked_slugs - - # --- liked-ids --- - async def _liked_ids(): - """Return all liked target_ids for a user + target_type.""" - from sqlalchemy import select - from models.like import Like - - user_id = request.args.get("user_id", type=int) - target_type = request.args.get("target_type", "") - - if not user_id or not target_type: - return [] - - result = await g.s.execute( - select(Like.target_id).where( - Like.user_id == user_id, - Like.target_type == target_type, - Like.target_id.isnot(None), - Like.deleted_at.is_(None), - ) - ) - return list(result.scalars().all()) - - _handlers["liked-ids"] = _liked_ids - + bp, _handlers = create_data_blueprint("likes") return bp diff --git a/likes/queries.sx b/likes/queries.sx new file mode 100644 index 0000000..67c901d --- /dev/null +++ b/likes/queries.sx @@ -0,0 +1,18 @@ +;; Likes service — inter-service data queries + +(defquery is-liked (&key user-id target-type target-slug target-id) + "Check if a user has liked a specific target." + (let ((result (service "likes" "is-liked" + :user-id user-id :target-type target-type + :target-slug target-slug :target-id target-id))) + {"liked" result})) + +(defquery liked-slugs (&key user-id target-type) + "Return all liked target_slugs for a user + target_type." + (service "likes" "liked-slugs" + :user-id user-id :target-type target-type)) + +(defquery liked-ids (&key user-id target-type) + "Return all liked target_ids for a user + target_type." + (service "likes" "liked-ids" + :user-id user-id :target-type target-type)) diff --git a/likes/services/__init__.py b/likes/services/__init__.py index be93bf2..9a14d42 100644 --- a/likes/services/__init__.py +++ b/likes/services/__init__.py @@ -4,3 +4,6 @@ from __future__ import annotations def register_domain_services() -> None: """Register services for the likes app.""" + from shared.services.registry import services + from shared.services.likes_impl import SqlLikesService + services.likes = SqlLikesService() diff --git a/market/actions.sx b/market/actions.sx new file mode 100644 index 0000000..8d2832c --- /dev/null +++ b/market/actions.sx @@ -0,0 +1,15 @@ +;; Market service — inter-service action endpoints + +(defaction create-marketplace (&key container-type container-id name slug) + "Create a new marketplace within a container." + (let ((mp (service "market" "create-marketplace" + :container-type container-type :container-id container-id + :name name :slug slug))) + mp)) + +(defaction soft-delete-marketplace (&key container-type container-id slug) + "Soft-delete a marketplace by slug within a container." + (let ((deleted (service "market" "soft-delete-marketplace" + :container-type container-type :container-id container-id + :slug slug))) + {"deleted" deleted})) diff --git a/market/bp/actions/routes.py b/market/bp/actions/routes.py index 16ca0a2..386e850 100644 --- a/market/bp/actions/routes.py +++ b/market/bp/actions/routes.py @@ -1,74 +1,14 @@ """Market app action endpoints. -Exposes write operations at ``/internal/actions/`` for -cross-app callers (blog, events) via the internal action client. +All actions are defined in ``market/actions.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.actions import ACTION_HEADER -from shared.services.registry import services +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") - - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- create-marketplace --- - async def _create_marketplace(): - data = await request.get_json() - mp = await services.market.create_marketplace( - g.s, - data["container_type"], - data["container_id"], - data["name"], - data["slug"], - ) - return { - "id": mp.id, - "container_type": mp.container_type, - "container_id": mp.container_id, - "name": mp.name, - "slug": mp.slug, - "description": mp.description, - } - - _handlers["create-marketplace"] = _create_marketplace - - # --- soft-delete-marketplace --- - async def _soft_delete_marketplace(): - data = await request.get_json() - deleted = await services.market.soft_delete_marketplace( - g.s, - data["container_type"], - data["container_id"], - data["slug"], - ) - return {"deleted": deleted} - - _handlers["soft-delete-marketplace"] = _soft_delete_marketplace - + bp, _handlers = create_action_blueprint("market") return bp diff --git a/market/bp/data/routes.py b/market/bp/data/routes.py index a24b90b..c210a02 100644 --- a/market/bp/data/routes.py +++ b/market/bp/data/routes.py @@ -1,110 +1,14 @@ """Market app data endpoints. -Exposes read-only JSON queries at ``/internal/data/`` for -cross-app callers via the internal data client. +All queries are defined in ``market/queries.sx``. """ from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER -from shared.contracts.dtos import dto_to_dict -from shared.services.registry import services +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- marketplaces-for-container --- - async def _marketplaces_for_container(): - container_type = request.args.get("type", "") - container_id = request.args.get("id", type=int) - markets = await services.market.marketplaces_for_container( - g.s, container_type, container_id, - ) - return [dto_to_dict(m) for m in markets] - - _handlers["marketplaces-for-container"] = _marketplaces_for_container - - # --- products-by-ids --- - async def _products_by_ids(): - """Return product details for a list of IDs (comma-separated).""" - from sqlalchemy import select - from shared.models.market import Product - - ids_raw = request.args.get("ids", "") - try: - ids = [int(x) for x in ids_raw.split(",") if x.strip()] - except ValueError: - return {"error": "ids must be comma-separated integers"}, 400 - if not ids: - return [] - - rows = (await g.s.execute( - select(Product).where(Product.id.in_(ids)) - )).scalars().all() - - return [ - { - "id": p.id, - "title": p.title, - "slug": p.slug, - "image": p.image, - "regular_price": str(p.regular_price) if p.regular_price is not None else None, - "special_price": str(p.special_price) if p.special_price is not None else None, - } - for p in rows - ] - - _handlers["products-by-ids"] = _products_by_ids - - # --- marketplaces-by-ids --- - async def _marketplaces_by_ids(): - """Return marketplace data for a list of IDs (comma-separated).""" - from sqlalchemy import select - from shared.models.market_place import MarketPlace - - ids_raw = request.args.get("ids", "") - try: - ids = [int(x) for x in ids_raw.split(",") if x.strip()] - except ValueError: - return {"error": "ids must be comma-separated integers"}, 400 - if not ids: - return [] - - rows = (await g.s.execute( - select(MarketPlace).where(MarketPlace.id.in_(ids)) - )).scalars().all() - - return [ - { - "id": m.id, - "name": m.name, - "slug": m.slug, - "container_type": m.container_type, - "container_id": m.container_id, - } - for m in rows - ] - - _handlers["marketplaces-by-ids"] = _marketplaces_by_ids - + bp, _handlers = create_data_blueprint("market") return bp diff --git a/market/queries.sx b/market/queries.sx new file mode 100644 index 0000000..a2d2def --- /dev/null +++ b/market/queries.sx @@ -0,0 +1,14 @@ +;; Market service — inter-service data queries + +(defquery marketplaces-for-container (&key type id) + "Marketplaces attached to a container (page, etc)." + (service "market" "marketplaces-for-container" + :container-type type :container-id id)) + +(defquery products-by-ids (&key ids) + "Return product details for comma-separated IDs." + (service "market-data" "products-by-ids" :ids (split-ids ids))) + +(defquery marketplaces-by-ids (&key ids) + "Return marketplace data for comma-separated IDs." + (service "market-data" "marketplaces-by-ids" :ids (split-ids ids))) diff --git a/market/services/__init__.py b/market/services/__init__.py index fcde49c..27edf83 100644 --- a/market/services/__init__.py +++ b/market/services/__init__.py @@ -14,6 +14,9 @@ def register_domain_services() -> None: services.market = SqlMarketService() + from shared.services.market_data_impl import SqlMarketDataService + services.register("market_data", SqlMarketDataService()) + # Federation needed for AP shared infrastructure (activitypub blueprint) if not services.has("federation"): from shared.services.federation_impl import SqlFederationService diff --git a/orders/actions.sx b/orders/actions.sx new file mode 100644 index 0000000..a2d5ec0 --- /dev/null +++ b/orders/actions.sx @@ -0,0 +1,4 @@ +;; Orders service — inter-service action endpoints +;; +;; create-order has complex multi-step logic (SumUp checkout creation) — +;; remains as Python fallback. diff --git a/orders/bp/actions/routes.py b/orders/bp/actions/routes.py index dd4e4d5..3a18577 100644 --- a/orders/bp/actions/routes.py +++ b/orders/bp/actions/routes.py @@ -1,40 +1,18 @@ -"""Orders app action endpoints.""" +"""Orders app action endpoints. + +create-order remains as a Python fallback (complex multi-step SumUp checkout). +""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint, g, request -from shared.infrastructure.actions import ACTION_HEADER +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + bp, _handlers = create_action_blueprint("orders") - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- create-order --- async def _create_order(): - """Create an order from cart data. Called by cart during checkout.""" from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.infrastructure.urls import orders_url from services.checkout import ( @@ -75,7 +53,6 @@ def register() -> Blueprint: order.sumup_reference = build_sumup_reference(order.id, page_config=page_config) description = build_sumup_description(cart_items, order.id, ticket_count=len(tickets)) - # Build URLs using orders service's own domain redirect_url = orders_url(f"/checkout/return/{order.id}/") webhook_base_url = orders_url(f"/checkout/webhook/{order.id}/") webhook_url = build_webhook_url(webhook_base_url) diff --git a/orders/bp/data/routes.py b/orders/bp/data/routes.py index 5f6d846..c775dc6 100644 --- a/orders/bp/data/routes.py +++ b/orders/bp/data/routes.py @@ -1,30 +1,11 @@ """Orders app data endpoints.""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - + bp, _handlers = create_data_blueprint("orders") return bp diff --git a/relations/actions.sx b/relations/actions.sx new file mode 100644 index 0000000..ba0bd72 --- /dev/null +++ b/relations/actions.sx @@ -0,0 +1,36 @@ +;; Relations service — inter-service action endpoints + +(defaction attach-child (&key parent-type parent-id child-type child-id + label sort-order relation-type metadata) + "Create or revive a container relation." + (service "relations" "attach-child" + :parent-type parent-type :parent-id parent-id + :child-type child-type :child-id child-id + :label label :sort-order sort-order + :relation-type relation-type :metadata metadata)) + +(defaction detach-child (&key parent-type parent-id child-type child-id relation-type) + "Soft-delete a container relation." + (let ((deleted (service "relations" "detach-child" + :parent-type parent-type :parent-id parent-id + :child-type child-type :child-id child-id + :relation-type relation-type))) + {"deleted" deleted})) + +(defaction relate (&key relation-type from-id to-id label sort-order metadata) + "Create a typed relation with registry validation and cardinality enforcement." + (service "relations" "relate" + :relation-type relation-type :from-id from-id :to-id to-id + :label label :sort-order sort-order :metadata metadata)) + +(defaction unrelate (&key relation-type from-id to-id) + "Remove a typed relation with registry validation." + (let ((deleted (service "relations" "unrelate" + :relation-type relation-type + :from-id from-id :to-id to-id))) + {"deleted" deleted})) + +(defaction can-relate (&key relation-type from-id) + "Check if a relation can be created (cardinality, registry validation)." + (service "relations" "can-relate" + :relation-type relation-type :from-id from-id)) diff --git a/relations/bp/actions/routes.py b/relations/bp/actions/routes.py index edd8e5f..5334dc7 100644 --- a/relations/bp/actions/routes.py +++ b/relations/bp/actions/routes.py @@ -1,194 +1,14 @@ -"""Relations app action endpoints.""" +"""Relations app action endpoints. + +All actions are defined in ``relations/actions.sx``. +""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.actions import ACTION_HEADER +from shared.infrastructure.query_blueprint import create_action_blueprint def register() -> Blueprint: - bp = Blueprint("actions", __name__, url_prefix="/internal/actions") - - @bp.before_request - async def _require_action_header(): - if not request.headers.get(ACTION_HEADER): - return jsonify({"error": "forbidden"}), 403 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.post("/") - async def handle_action(action_name: str): - handler = _handlers.get(action_name) - if handler is None: - return jsonify({"error": "unknown action"}), 404 - try: - result = await handler() - return jsonify(result) - except Exception as exc: - import logging - logging.getLogger(__name__).exception("Action %s failed", action_name) - return jsonify({"error": str(exc)}), 500 - - # --- attach-child --- - async def _attach_child(): - """Create or revive a ContainerRelation.""" - from shared.services.relationships import attach_child - - data = await request.get_json(force=True) - rel = await attach_child( - g.s, - parent_type=data["parent_type"], - parent_id=data["parent_id"], - child_type=data["child_type"], - child_id=data["child_id"], - label=data.get("label"), - sort_order=data.get("sort_order"), - relation_type=data.get("relation_type"), - metadata=data.get("metadata"), - ) - return { - "id": rel.id, - "parent_type": rel.parent_type, - "parent_id": rel.parent_id, - "child_type": rel.child_type, - "child_id": rel.child_id, - "sort_order": rel.sort_order, - "relation_type": rel.relation_type, - } - - _handlers["attach-child"] = _attach_child - - # --- detach-child --- - async def _detach_child(): - """Soft-delete a ContainerRelation.""" - from shared.services.relationships import detach_child - - data = await request.get_json(force=True) - deleted = await detach_child( - g.s, - parent_type=data["parent_type"], - parent_id=data["parent_id"], - child_type=data["child_type"], - child_id=data["child_id"], - relation_type=data.get("relation_type"), - ) - return {"deleted": deleted} - - _handlers["detach-child"] = _detach_child - - # --- relate (registry-aware) --- - async def _relate(): - """Create a typed relation with registry validation and cardinality enforcement.""" - from shared.services.relationships import attach_child, get_children - from shared.sx.relations import get_relation - - data = await request.get_json(force=True) - rel_type = data.get("relation_type") - if not rel_type: - return {"error": "relation_type is required"}, 400 - - defn = get_relation(rel_type) - if defn is None: - return {"error": f"unknown relation_type: {rel_type}"}, 400 - - from_id = data["from_id"] - to_id = data["to_id"] - - # Cardinality enforcement - if defn.cardinality == "one-to-one": - existing = await get_children( - g.s, - parent_type=defn.from_type, - parent_id=from_id, - child_type=defn.to_type, - relation_type=rel_type, - ) - if existing: - return {"error": "one-to-one relation already exists", "existing_id": existing[0].child_id}, 409 - - rel = await attach_child( - g.s, - parent_type=defn.from_type, - parent_id=from_id, - child_type=defn.to_type, - child_id=to_id, - label=data.get("label"), - sort_order=data.get("sort_order"), - relation_type=rel_type, - metadata=data.get("metadata"), - ) - return { - "id": rel.id, - "relation_type": rel.relation_type, - "parent_type": rel.parent_type, - "parent_id": rel.parent_id, - "child_type": rel.child_type, - "child_id": rel.child_id, - "sort_order": rel.sort_order, - } - - _handlers["relate"] = _relate - - # --- unrelate (registry-aware) --- - async def _unrelate(): - """Remove a typed relation with registry validation.""" - from shared.services.relationships import detach_child - from shared.sx.relations import get_relation - - data = await request.get_json(force=True) - rel_type = data.get("relation_type") - if not rel_type: - return {"error": "relation_type is required"}, 400 - - defn = get_relation(rel_type) - if defn is None: - return {"error": f"unknown relation_type: {rel_type}"}, 400 - - deleted = await detach_child( - g.s, - parent_type=defn.from_type, - parent_id=data["from_id"], - child_type=defn.to_type, - child_id=data["to_id"], - relation_type=rel_type, - ) - return {"deleted": deleted} - - _handlers["unrelate"] = _unrelate - - # --- can-relate (pre-flight check) --- - async def _can_relate(): - """Check if a relation can be created (cardinality, registry validation).""" - from shared.services.relationships import get_children - from shared.sx.relations import get_relation - - data = await request.get_json(force=True) - rel_type = data.get("relation_type") - if not rel_type: - return {"error": "relation_type is required"}, 400 - - defn = get_relation(rel_type) - if defn is None: - return {"allowed": False, "reason": f"unknown relation_type: {rel_type}"} - - from_id = data["from_id"] - - if defn.cardinality == "one-to-one": - existing = await get_children( - g.s, - parent_type=defn.from_type, - parent_id=from_id, - child_type=defn.to_type, - relation_type=rel_type, - ) - if existing: - return {"allowed": False, "reason": "one-to-one relation already exists"} - - return {"allowed": True} - - _handlers["can-relate"] = _can_relate - + bp, _handlers = create_action_blueprint("relations") return bp diff --git a/relations/bp/data/routes.py b/relations/bp/data/routes.py index c37f784..d6b452e 100644 --- a/relations/bp/data/routes.py +++ b/relations/bp/data/routes.py @@ -1,77 +1,14 @@ -"""Relations app data endpoints.""" +"""Relations app data endpoints. + +All queries are defined in ``relations/queries.sx``. +""" from __future__ import annotations -from quart import Blueprint, g, jsonify, request +from quart import Blueprint -from shared.infrastructure.data_client import DATA_HEADER +from shared.infrastructure.query_blueprint import create_data_blueprint 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 - from shared.infrastructure.internal_auth import validate_internal_request - if not validate_internal_request(): - return jsonify({"error": "forbidden"}), 403 - - _handlers: dict[str, object] = {} - - @bp.get("/") - 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) - - # --- get-children --- - async def _get_children(): - """Return ContainerRelation children for a parent.""" - from shared.services.relationships import get_children - - parent_type = request.args.get("parent_type", "") - parent_id = request.args.get("parent_id", type=int) - child_type = request.args.get("child_type") - relation_type = request.args.get("relation_type") - if not parent_type or parent_id is None: - return [] - rels = await get_children(g.s, parent_type, parent_id, child_type, relation_type=relation_type) - return [_serialize_rel(r) for r in rels] - - _handlers["get-children"] = _get_children - - # --- get-parents --- - async def _get_parents(): - """Return ContainerRelation parents for a child.""" - from shared.services.relationships import get_parents - - child_type = request.args.get("child_type", "") - child_id = request.args.get("child_id", type=int) - parent_type = request.args.get("parent_type") - relation_type = request.args.get("relation_type") - if not child_type or child_id is None: - return [] - rels = await get_parents(g.s, child_type, child_id, parent_type, relation_type=relation_type) - return [_serialize_rel(r) for r in rels] - - _handlers["get-parents"] = _get_parents - + bp, _handlers = create_data_blueprint("relations") return bp - - -def _serialize_rel(r): - """Serialize a ContainerRelation to a dict.""" - return { - "id": r.id, - "parent_type": r.parent_type, - "parent_id": r.parent_id, - "child_type": r.child_type, - "child_id": r.child_id, - "sort_order": r.sort_order, - "label": r.label, - "relation_type": r.relation_type, - "metadata": r.metadata_, - } diff --git a/relations/queries.sx b/relations/queries.sx new file mode 100644 index 0000000..9a3ebfd --- /dev/null +++ b/relations/queries.sx @@ -0,0 +1,13 @@ +;; Relations service — inter-service data queries + +(defquery get-children (&key parent-type parent-id child-type relation-type) + "Return child relations for a parent." + (service "relations" "get-children" + :parent-type parent-type :parent-id parent-id + :child-type child-type :relation-type relation-type)) + +(defquery get-parents (&key child-type child-id parent-type relation-type) + "Return parent relations for a child." + (service "relations" "get-parents" + :child-type child-type :child-id child-id + :parent-type parent-type :relation-type relation-type)) diff --git a/relations/services/__init__.py b/relations/services/__init__.py index faa4bb1..4645c6a 100644 --- a/relations/services/__init__.py +++ b/relations/services/__init__.py @@ -4,3 +4,6 @@ from __future__ import annotations def register_domain_services() -> None: """Register services for the relations app.""" + from shared.services.registry import services + from shared.services.relations_impl import SqlRelationsService + services.register("relations", SqlRelationsService()) diff --git a/shared/contracts/likes.py b/shared/contracts/likes.py new file mode 100644 index 0000000..1aa1527 --- /dev/null +++ b/shared/contracts/likes.py @@ -0,0 +1,31 @@ +"""Protocol for the Likes domain service.""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from sqlalchemy.ext.asyncio import AsyncSession + + +@runtime_checkable +class LikesService(Protocol): + async def is_liked( + self, session: AsyncSession, *, + user_id: int, target_type: str, + target_slug: str | None = None, target_id: int | None = None, + ) -> bool: ... + + async def liked_slugs( + self, session: AsyncSession, *, + user_id: int, target_type: str, + ) -> list[str]: ... + + async def liked_ids( + self, session: AsyncSession, *, + user_id: int, target_type: str, + ) -> list[int]: ... + + async def toggle( + self, session: AsyncSession, *, + user_id: int, target_type: str, + target_slug: str | None = None, target_id: int | None = None, + ) -> bool: ... diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index c26d4d4..188f581 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -117,6 +117,15 @@ def create_base_app( load_shared_components() load_relation_registry() + # Load defquery/defaction definitions from {service}/queries.sx and actions.sx + from shared.sx.query_registry import load_service_protocols + _app_root = Path(os.getcwd()) + load_service_protocols(name, str(_app_root)) + + # Register /internal/schema endpoint for protocol introspection + from shared.infrastructure.schema_blueprint import create_schema_blueprint + app.register_blueprint(create_schema_blueprint(name)) + # Load CSS registry (tw.css → class-to-rule lookup for on-demand CSS) from shared.sx.css_registry import load_css_registry, registry_loaded _styles = BASE_DIR / "static" / "styles" diff --git a/shared/infrastructure/protocol_manifest.py b/shared/infrastructure/protocol_manifest.py new file mode 100644 index 0000000..0e498a3 --- /dev/null +++ b/shared/infrastructure/protocol_manifest.py @@ -0,0 +1,58 @@ +"""Protocol manifest — aggregates /internal/schema from all services. + +Can be used as a CLI tool or imported for dev-mode inspection. + +Usage:: + + python -m shared.infrastructure.protocol_manifest +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from shared.infrastructure.data_client import fetch_data + + +# Service names that have inter-service protocols +_SERVICES = [ + "blog", "market", "cart", "events", "account", + "likes", "relations", "orders", +] + + +async def fetch_service_schema(service: str) -> dict[str, Any] | None: + """Fetch /internal/schema from a single service.""" + try: + from shared.infrastructure.urls import service_url + import aiohttp + url = service_url(service, "/internal/schema") + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp: + if resp.status == 200: + return await resp.json() + except Exception: + return None + return None + + +async def generate_manifest() -> dict[str, Any]: + """Fetch schemas from all services and produce a unified protocol map.""" + results = await asyncio.gather( + *(fetch_service_schema(s) for s in _SERVICES), + return_exceptions=True, + ) + manifest = {"services": {}} + for service, result in zip(_SERVICES, results): + if isinstance(result, dict): + manifest["services"][service] = result + else: + manifest["services"][service] = {"error": str(result) if isinstance(result, Exception) else "unavailable"} + return manifest + + +if __name__ == "__main__": + m = asyncio.run(generate_manifest()) + print(json.dumps(m, indent=2)) diff --git a/shared/infrastructure/query_blueprint.py b/shared/infrastructure/query_blueprint.py new file mode 100644 index 0000000..2a0818a --- /dev/null +++ b/shared/infrastructure/query_blueprint.py @@ -0,0 +1,127 @@ +""" +Blueprint factories for sx-dispatched data and action routes. + +Replaces per-service boilerplate in ``bp/data/routes.py`` and +``bp/actions/routes.py`` by dispatching to defquery/defaction definitions +from the sx query registry. Falls back to Python ``_handlers`` dicts +for queries/actions not yet converted. + +Usage:: + + from shared.infrastructure.query_blueprint import ( + create_data_blueprint, create_action_blueprint, + ) + + # In service's bp/data/routes.py: + def register() -> Blueprint: + bp, _handlers = create_data_blueprint("events") + # Optional Python fallback handlers: + # _handlers["some-query"] = _some_python_handler + return bp +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Awaitable + +from quart import Blueprint, g, jsonify, request + +logger = logging.getLogger("sx.query_blueprint") + + +def create_data_blueprint( + service_name: str, +) -> tuple[Blueprint, dict[str, Callable[[], Awaitable[Any]]]]: + """Create a data blueprint that dispatches to sx queries with Python fallback. + + Returns (blueprint, python_handlers_dict) so the caller can register + Python fallback handlers for queries not yet converted to sx. + """ + from shared.infrastructure.data_client import DATA_HEADER + + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + _handlers: dict[str, Callable[[], Awaitable[Any]]] = {} + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + @bp.get("/") + async def handle_query(query_name: str): + # 1. Check sx query registry first + from shared.sx.query_registry import get_query + from shared.sx.query_executor import execute_query + + qdef = get_query(service_name, query_name) + if qdef is not None: + result = await execute_query(qdef, dict(request.args)) + return jsonify(result) + + # 2. Fall back to Python handlers + handler = _handlers.get(query_name) + if handler is not None: + result = await handler() + return jsonify(result) + + return jsonify({"error": "unknown query"}), 404 + + return bp, _handlers + + +def create_action_blueprint( + service_name: str, +) -> tuple[Blueprint, dict[str, Callable[[], Awaitable[Any]]]]: + """Create an action blueprint that dispatches to sx actions with Python fallback. + + Returns (blueprint, python_handlers_dict) so the caller can register + Python fallback handlers for actions not yet converted to sx. + """ + from shared.infrastructure.actions import ACTION_HEADER + + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + _handlers: dict[str, Callable[[], Awaitable[Any]]] = {} + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + @bp.post("/") + async def handle_action(action_name: str): + # 1. Check sx action registry first + from shared.sx.query_registry import get_action + from shared.sx.query_executor import execute_action + + adef = get_action(service_name, action_name) + if adef is not None: + try: + payload = await request.get_json(force=True) or {} + result = await execute_action(adef, payload) + return jsonify(result) + except Exception as exc: + logger.exception("SX action %s:%s failed", service_name, action_name) + return jsonify({"error": str(exc)}), 500 + + # 2. Fall back to Python handlers + handler = _handlers.get(action_name) + if handler is not None: + try: + result = await handler() + return jsonify(result) + except Exception as exc: + logger.exception("Action %s failed", action_name) + return jsonify({"error": str(exc)}), 500 + + return jsonify({"error": "unknown action"}), 404 + + return bp, _handlers diff --git a/shared/infrastructure/schema_blueprint.py b/shared/infrastructure/schema_blueprint.py new file mode 100644 index 0000000..ddc1598 --- /dev/null +++ b/shared/infrastructure/schema_blueprint.py @@ -0,0 +1,31 @@ +"""Schema endpoint for inter-service protocol introspection. + +Every service exposes ``GET /internal/schema`` which returns a JSON +manifest of all defquery and defaction definitions with their parameter +signatures and docstrings. + +Usage:: + + from shared.infrastructure.schema_blueprint import create_schema_blueprint + app.register_blueprint(create_schema_blueprint("events")) +""" + +from __future__ import annotations + +from quart import Blueprint, jsonify, request + + +def create_schema_blueprint(service_name: str) -> Blueprint: + """Create a blueprint exposing ``/internal/schema``.""" + bp = Blueprint( + f"schema_{service_name}", + __name__, + url_prefix="/internal", + ) + + @bp.get("/schema") + async def get_schema(): + from shared.sx.query_registry import schema_for_service + return jsonify(schema_for_service(service_name)) + + return bp diff --git a/shared/services/account_impl.py b/shared/services/account_impl.py new file mode 100644 index 0000000..519de47 --- /dev/null +++ b/shared/services/account_impl.py @@ -0,0 +1,42 @@ +"""Service methods for account data queries. + +Extracted from account/bp/data/routes.py to enable sx defquery conversion. +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models import User + + +class SqlAccountDataService: + + async def user_by_email( + self, session: AsyncSession, *, email: str, + ) -> dict[str, Any] | None: + email = (email or "").strip().lower() + if not email: + return None + result = await session.execute( + select(User.id).where(User.email.ilike(email)) + ) + row = result.first() + if not row: + return None + return {"user_id": row[0]} + + async def newsletters(self, session: AsyncSession) -> list[dict[str, Any]]: + from shared.models.ghost_membership_entities import GhostNewsletter + result = await session.execute( + select( + GhostNewsletter.id, GhostNewsletter.ghost_id, + GhostNewsletter.name, GhostNewsletter.slug, + ).order_by(GhostNewsletter.name) + ) + return [ + {"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]} + for row in result.all() + ] diff --git a/shared/services/cart_items_impl.py b/shared/services/cart_items_impl.py new file mode 100644 index 0000000..c672a72 --- /dev/null +++ b/shared/services/cart_items_impl.py @@ -0,0 +1,37 @@ +"""Extra cart query methods not in the CartService protocol. + +cart-items returns raw CartItem data without going through CartSummaryDTO. +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.market import CartItem + + +class SqlCartItemsService: + + async def cart_items( + self, session: AsyncSession, *, + user_id: int | None = None, session_id: str | None = None, + ) -> list[dict[str, Any]]: + filters = [CartItem.deleted_at.is_(None)] + if user_id is not None: + filters.append(CartItem.user_id == user_id) + elif session_id is not None: + filters.append(CartItem.session_id == session_id) + else: + return [] + + result = await session.execute(select(CartItem).where(*filters)) + return [ + { + "product_id": item.product_id, + "product_slug": item.product_slug, + "quantity": item.quantity, + } + for item in result.scalars().all() + ] diff --git a/shared/services/likes_impl.py b/shared/services/likes_impl.py new file mode 100644 index 0000000..35d7983 --- /dev/null +++ b/shared/services/likes_impl.py @@ -0,0 +1,103 @@ +"""SQL implementation of the LikesService protocol. + +Extracted from likes/bp/data/routes.py and likes/bp/actions/routes.py +to enable sx defquery/defaction conversion. +""" +from __future__ import annotations + +from sqlalchemy import select, update, func +from sqlalchemy.ext.asyncio import AsyncSession + +from likes.models.like import Like + + +class SqlLikesService: + + async def is_liked( + self, session: AsyncSession, *, + user_id: int, target_type: str, + target_slug: str | None = None, target_id: int | None = None, + ) -> bool: + if not user_id or not target_type: + return False + filters = [ + Like.user_id == user_id, + Like.target_type == target_type, + Like.deleted_at.is_(None), + ] + if target_slug is not None: + filters.append(Like.target_slug == target_slug) + elif target_id is not None: + filters.append(Like.target_id == target_id) + else: + return False + row = await session.scalar(select(Like.id).where(*filters)) + return row is not None + + async def liked_slugs( + self, session: AsyncSession, *, + user_id: int, target_type: str, + ) -> list[str]: + if not user_id or not target_type: + return [] + result = await session.execute( + select(Like.target_slug).where( + Like.user_id == user_id, + Like.target_type == target_type, + Like.target_slug.isnot(None), + Like.deleted_at.is_(None), + ) + ) + return list(result.scalars().all()) + + async def liked_ids( + self, session: AsyncSession, *, + user_id: int, target_type: str, + ) -> list[int]: + if not user_id or not target_type: + return [] + result = await session.execute( + select(Like.target_id).where( + Like.user_id == user_id, + Like.target_type == target_type, + Like.target_id.isnot(None), + Like.deleted_at.is_(None), + ) + ) + return list(result.scalars().all()) + + async def toggle( + self, session: AsyncSession, *, + user_id: int, target_type: str, + target_slug: str | None = None, target_id: int | None = None, + ) -> bool: + """Toggle a like. Returns True if now liked, False if unliked.""" + filters = [ + Like.user_id == user_id, + Like.target_type == target_type, + Like.deleted_at.is_(None), + ] + if target_slug is not None: + filters.append(Like.target_slug == target_slug) + elif target_id is not None: + filters.append(Like.target_id == target_id) + else: + raise ValueError("target_slug or target_id required") + + existing = await session.scalar(select(Like).where(*filters)) + + if existing: + await session.execute( + update(Like).where(Like.id == existing.id).values(deleted_at=func.now()) + ) + return False + else: + new_like = Like( + user_id=user_id, + target_type=target_type, + target_slug=target_slug, + target_id=target_id, + ) + session.add(new_like) + await session.flush() + return True diff --git a/shared/services/market_data_impl.py b/shared/services/market_data_impl.py new file mode 100644 index 0000000..420a1b7 --- /dev/null +++ b/shared/services/market_data_impl.py @@ -0,0 +1,55 @@ +"""Extra market query methods for raw-SQLAlchemy data lookups. + +products-by-ids and marketplaces-by-ids use direct selects rather +than the MarketService protocol methods. +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class SqlMarketDataService: + + async def products_by_ids( + self, session: AsyncSession, *, ids: list[int], + ) -> list[dict[str, Any]]: + if not ids: + return [] + from shared.models.market import Product + rows = (await session.execute( + select(Product).where(Product.id.in_(ids)) + )).scalars().all() + return [ + { + "id": p.id, + "title": p.title, + "slug": p.slug, + "image": p.image, + "regular_price": str(p.regular_price) if p.regular_price is not None else None, + "special_price": str(p.special_price) if p.special_price is not None else None, + } + for p in rows + ] + + async def marketplaces_by_ids( + self, session: AsyncSession, *, ids: list[int], + ) -> list[dict[str, Any]]: + if not ids: + return [] + from shared.models.market_place import MarketPlace + rows = (await session.execute( + select(MarketPlace).where(MarketPlace.id.in_(ids)) + )).scalars().all() + return [ + { + "id": m.id, + "name": m.name, + "slug": m.slug, + "container_type": m.container_type, + "container_id": m.container_id, + } + for m in rows + ] diff --git a/shared/services/page_config_impl.py b/shared/services/page_config_impl.py new file mode 100644 index 0000000..4223a7a --- /dev/null +++ b/shared/services/page_config_impl.py @@ -0,0 +1,137 @@ +"""SQL implementation of PageConfig service methods. + +Extracted from blog/bp/data/routes.py and blog/bp/actions/routes.py +to enable sx defquery/defaction conversion. +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified + +from shared.models.page_config import PageConfig + + +def _to_dict(pc: PageConfig) -> dict[str, Any]: + return { + "id": pc.id, + "container_type": pc.container_type, + "container_id": pc.container_id, + "features": pc.features or {}, + "sumup_merchant_code": pc.sumup_merchant_code, + "sumup_api_key": pc.sumup_api_key, + "sumup_checkout_prefix": pc.sumup_checkout_prefix, + } + + +class SqlPageConfigService: + + async def ensure( + self, session: AsyncSession, *, + container_type: str = "page", container_id: int, + ) -> dict[str, Any]: + """Get or create a PageConfig. Returns minimal dict with id.""" + row = (await session.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id == container_id, + ) + )).scalar_one_or_none() + + if row is None: + row = PageConfig( + container_type=container_type, + container_id=container_id, + features={}, + ) + session.add(row) + await session.flush() + + return { + "id": row.id, + "container_type": row.container_type, + "container_id": row.container_id, + } + + async def get_by_container( + self, session: AsyncSession, *, + container_type: str = "page", container_id: int, + ) -> dict[str, Any] | None: + pc = (await session.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id == container_id, + ) + )).scalar_one_or_none() + return _to_dict(pc) if pc else None + + async def get_by_id( + self, session: AsyncSession, *, id: int, + ) -> dict[str, Any] | None: + pc = await session.get(PageConfig, id) + return _to_dict(pc) if pc else None + + async def get_batch( + self, session: AsyncSession, *, + container_type: str = "page", ids: list[int], + ) -> list[dict[str, Any]]: + if not ids: + return [] + result = await session.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id.in_(ids), + ) + ) + return [_to_dict(pc) for pc in result.scalars().all()] + + async def update( + self, session: AsyncSession, *, + container_type: str = "page", container_id: int, + features: dict | None = None, + sumup_merchant_code: str | None = None, + sumup_checkout_prefix: str | None = None, + sumup_api_key: str | None = None, + ) -> dict[str, Any]: + pc = (await session.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id == container_id, + ) + )).scalar_one_or_none() + + if pc is None: + pc = PageConfig( + container_type=container_type, + container_id=container_id, + features=features or {}, + ) + session.add(pc) + await session.flush() + + if features is not None: + merged = dict(pc.features or {}) + for key, val in features.items(): + if isinstance(val, bool): + merged[key] = val + elif val in ("true", "1", "on"): + merged[key] = True + elif val in ("false", "0", "off", None): + merged[key] = False + pc.features = merged + flag_modified(pc, "features") + + if sumup_merchant_code is not None: + pc.sumup_merchant_code = sumup_merchant_code or None + if sumup_checkout_prefix is not None: + pc.sumup_checkout_prefix = sumup_checkout_prefix or None + if sumup_api_key is not None: + pc.sumup_api_key = sumup_api_key or None + + await session.flush() + + result = _to_dict(pc) + result["sumup_configured"] = bool(pc.sumup_api_key) + return result diff --git a/shared/services/registry.py b/shared/services/registry.py index 8616664..af4f3fe 100644 --- a/shared/services/registry.py +++ b/shared/services/registry.py @@ -23,6 +23,7 @@ from shared.contracts.protocols import ( CartService, FederationService, ) +from shared.contracts.likes import LikesService class _ServiceRegistry: @@ -38,6 +39,7 @@ class _ServiceRegistry: self._market: MarketService | None = None self._cart: CartService | None = None self._federation: FederationService | None = None + self._likes: LikesService | None = None self._extra: dict[str, Any] = {} # -- calendar ------------------------------------------------------------- @@ -73,6 +75,17 @@ class _ServiceRegistry: def cart(self, impl: CartService) -> None: self._cart = impl + # -- likes ---------------------------------------------------------------- + @property + def likes(self) -> LikesService: + if self._likes is None: + raise RuntimeError("LikesService not registered") + return self._likes + + @likes.setter + def likes(self, impl: LikesService) -> None: + self._likes = impl + # -- federation ----------------------------------------------------------- @property def federation(self) -> FederationService: diff --git a/shared/services/relations_impl.py b/shared/services/relations_impl.py new file mode 100644 index 0000000..0fde26d --- /dev/null +++ b/shared/services/relations_impl.py @@ -0,0 +1,164 @@ +"""Service wrapper for relations module functions. + +Wraps the module-level functions in shared.services.relationships into +a class so they can be called via the ``(service "relations" ...)`` primitive. +""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + + +def _serialize_rel(r) -> dict[str, Any]: + return { + "id": r.id, + "parent_type": r.parent_type, + "parent_id": r.parent_id, + "child_type": r.child_type, + "child_id": r.child_id, + "sort_order": r.sort_order, + "label": r.label, + "relation_type": r.relation_type, + "metadata": r.metadata_, + } + + +class SqlRelationsService: + + async def get_children( + self, session: AsyncSession, *, + parent_type: str, parent_id: int, + child_type: str | None = None, + relation_type: str | None = None, + ) -> list[dict[str, Any]]: + from shared.services.relationships import get_children + rels = await get_children( + session, parent_type, parent_id, child_type, + relation_type=relation_type, + ) + return [_serialize_rel(r) for r in rels] + + async def get_parents( + self, session: AsyncSession, *, + child_type: str, child_id: int, + parent_type: str | None = None, + relation_type: str | None = None, + ) -> list[dict[str, Any]]: + from shared.services.relationships import get_parents + rels = await get_parents( + session, child_type, child_id, parent_type, + relation_type=relation_type, + ) + return [_serialize_rel(r) for r in rels] + + async def attach_child( + self, session: AsyncSession, *, + parent_type: str, parent_id: int, + child_type: str, child_id: int, + label: str | None = None, + sort_order: int | None = None, + relation_type: str | None = None, + metadata: dict | None = None, + ) -> dict[str, Any]: + from shared.services.relationships import attach_child + rel = await attach_child( + session, + parent_type=parent_type, parent_id=parent_id, + child_type=child_type, child_id=child_id, + label=label, sort_order=sort_order, + relation_type=relation_type, metadata=metadata, + ) + return _serialize_rel(rel) + + async def detach_child( + self, session: AsyncSession, *, + parent_type: str, parent_id: int, + child_type: str, child_id: int, + relation_type: str | None = None, + ) -> bool: + from shared.services.relationships import detach_child + return await detach_child( + session, + parent_type=parent_type, parent_id=parent_id, + child_type=child_type, child_id=child_id, + relation_type=relation_type, + ) + + async def relate( + self, session: AsyncSession, *, + relation_type: str, + from_id: int, to_id: int, + label: str | None = None, + sort_order: int | None = None, + metadata: dict | None = None, + ) -> dict[str, Any]: + """Registry-aware relation creation with cardinality enforcement.""" + from shared.services.relationships import attach_child, get_children + from shared.sx.relations import get_relation + + defn = get_relation(relation_type) + if defn is None: + raise ValueError(f"unknown relation_type: {relation_type}") + + if defn.cardinality == "one-to-one": + existing = await get_children( + session, + parent_type=defn.from_type, + parent_id=from_id, + child_type=defn.to_type, + relation_type=relation_type, + ) + if existing: + raise ValueError("one-to-one relation already exists") + + rel = await attach_child( + session, + parent_type=defn.from_type, parent_id=from_id, + child_type=defn.to_type, child_id=to_id, + label=label, sort_order=sort_order, + relation_type=relation_type, metadata=metadata, + ) + return _serialize_rel(rel) + + async def unrelate( + self, session: AsyncSession, *, + relation_type: str, from_id: int, to_id: int, + ) -> bool: + from shared.services.relationships import detach_child + from shared.sx.relations import get_relation + + defn = get_relation(relation_type) + if defn is None: + raise ValueError(f"unknown relation_type: {relation_type}") + + return await detach_child( + session, + parent_type=defn.from_type, parent_id=from_id, + child_type=defn.to_type, child_id=to_id, + relation_type=relation_type, + ) + + async def can_relate( + self, session: AsyncSession, *, + relation_type: str, from_id: int, + ) -> dict[str, Any]: + from shared.services.relationships import get_children + from shared.sx.relations import get_relation + + defn = get_relation(relation_type) + if defn is None: + return {"allowed": False, "reason": f"unknown relation_type: {relation_type}"} + + if defn.cardinality == "one-to-one": + existing = await get_children( + session, + parent_type=defn.from_type, + parent_id=from_id, + child_type=defn.to_type, + relation_type=relation_type, + ) + if existing: + return {"allowed": False, "reason": "one-to-one relation already exists"} + + return {"allowed": True} diff --git a/shared/sx/evaluator.py b/shared/sx/evaluator.py index d6a300f..7aa8582 100644 --- a/shared/sx/evaluator.py +++ b/shared/sx/evaluator.py @@ -553,6 +553,75 @@ def _sf_defhandler(expr: list, env: dict) -> HandlerDef: return handler +def _parse_key_params(params_expr: list) -> list[str]: + """Parse ``(&key param1 param2 ...)`` into a list of param name strings.""" + params: list[str] = [] + in_key = False + for p in params_expr: + if isinstance(p, Symbol): + if p.name == "&key": + in_key = True + continue + if in_key: + params.append(p.name) + elif isinstance(p, str): + params.append(p) + return params + + +def _sf_defquery(expr: list, env: dict): + """``(defquery name (&key param...) "docstring" body)``""" + from .types import QueryDef + if len(expr) < 4: + raise EvalError("defquery requires name, params, and body") + name_sym = expr[1] + if not isinstance(name_sym, Symbol): + raise EvalError(f"defquery name must be symbol, got {type(name_sym).__name__}") + params_expr = expr[2] + if not isinstance(params_expr, list): + raise EvalError("defquery params must be a list") + params = _parse_key_params(params_expr) + # Optional docstring before body + if len(expr) >= 5 and isinstance(expr[3], str): + doc = expr[3] + body = expr[4] + else: + doc = "" + body = expr[3] + qdef = QueryDef( + name=name_sym.name, params=params, doc=doc, + body=body, closure=dict(env), + ) + env[f"query:{name_sym.name}"] = qdef + return qdef + + +def _sf_defaction(expr: list, env: dict): + """``(defaction name (&key param...) "docstring" body)``""" + from .types import ActionDef + if len(expr) < 4: + raise EvalError("defaction requires name, params, and body") + name_sym = expr[1] + if not isinstance(name_sym, Symbol): + raise EvalError(f"defaction name must be symbol, got {type(name_sym).__name__}") + params_expr = expr[2] + if not isinstance(params_expr, list): + raise EvalError("defaction params must be a list") + params = _parse_key_params(params_expr) + if len(expr) >= 5 and isinstance(expr[3], str): + doc = expr[3] + body = expr[4] + else: + doc = "" + body = expr[3] + adef = ActionDef( + name=name_sym.name, params=params, doc=doc, + body=body, closure=dict(env), + ) + env[f"action:{name_sym.name}"] = adef + return adef + + def _sf_set_bang(expr: list, env: dict) -> Any: """``(set! name value)`` — mutate existing binding.""" if len(expr) != 3: @@ -737,6 +806,8 @@ _SPECIAL_FORMS: dict[str, Any] = { "quasiquote": _sf_quasiquote, "defhandler": _sf_defhandler, "defpage": _sf_defpage, + "defquery": _sf_defquery, + "defaction": _sf_defaction, } diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index 68bceac..d903587 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -516,6 +516,23 @@ def prim_parse_int(val: Any, default: Any = 0) -> int | Any: return default +@register_primitive("parse-datetime") +def prim_parse_datetime(val: Any) -> Any: + """``(parse-datetime "2024-01-15T10:00:00")`` → datetime object.""" + from datetime import datetime + if not val or val is NIL: + return NIL + return datetime.fromisoformat(str(val)) + + +@register_primitive("split-ids") +def prim_split_ids(val: Any) -> list[int]: + """``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs.""" + if not val or val is NIL: + return [] + return [int(x.strip()) for x in str(val).split(",") if x.strip()] + + # --------------------------------------------------------------------------- # Assertions # --------------------------------------------------------------------------- diff --git a/shared/sx/query_executor.py b/shared/sx/query_executor.py new file mode 100644 index 0000000..70cfece --- /dev/null +++ b/shared/sx/query_executor.py @@ -0,0 +1,70 @@ +""" +Execute defquery / defaction definitions. + +Unlike fragment handlers (which produce SX markup via ``async_eval_to_sx``), +query/action defs produce **data** (dicts, lists, scalars) that get +JSON-serialized by the calling blueprint. Uses ``async_eval()`` with +the I/O primitive pipeline so ``(service ...)`` calls are awaited inline. +""" + +from __future__ import annotations + +from typing import Any + +from .types import QueryDef, ActionDef, NIL + + +async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any: + """Execute a defquery and return a JSON-serializable result. + + Parameters are bound from request query string args. + """ + from .jinja_bridge import get_component_env, _get_request_context + from .async_eval import async_eval + + env = dict(get_component_env()) + env.update(query_def.closure) + + # Bind params from request args (try kebab-case and snake_case) + for param in query_def.params: + snake = param.replace("-", "_") + val = params.get(param, params.get(snake, NIL)) + # Coerce type=int for common patterns + if isinstance(val, str) and val.lstrip("-").isdigit(): + val = int(val) + env[param] = val + + ctx = _get_request_context() + result = await async_eval(query_def.body, env, ctx) + return _normalize(result) + + +async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any: + """Execute a defaction and return a JSON-serializable result. + + Parameters are bound from the JSON request body. + """ + from .jinja_bridge import get_component_env, _get_request_context + from .async_eval import async_eval + + env = dict(get_component_env()) + env.update(action_def.closure) + + # Bind params from JSON payload (try kebab-case and snake_case) + for param in action_def.params: + snake = param.replace("-", "_") + val = payload.get(param, payload.get(snake, NIL)) + env[param] = val + + ctx = _get_request_context() + result = await async_eval(action_def.body, env, ctx) + return _normalize(result) + + +def _normalize(value: Any) -> Any: + """Ensure result is JSON-serializable (strip NIL, convert sets, etc).""" + if value is NIL or value is None: + return None + if isinstance(value, set): + return list(value) + return value diff --git a/shared/sx/query_registry.py b/shared/sx/query_registry.py new file mode 100644 index 0000000..793925a --- /dev/null +++ b/shared/sx/query_registry.py @@ -0,0 +1,180 @@ +""" +Registry for defquery / defaction definitions. + +Mirrors the pattern in ``handlers.py`` but for inter-service data queries +and action endpoints. Each service loads its ``.sx`` files at startup, +and the registry makes them available for dispatch by the query blueprint. + +Usage:: + + from shared.sx.query_registry import load_query_file, get_query + + load_query_file("events/queries.sx", "events") + qdef = get_query("events", "pending-entries") +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from .types import QueryDef, ActionDef + +logger = logging.getLogger("sx.query_registry") + + +# --------------------------------------------------------------------------- +# Registry — service → name → QueryDef / ActionDef +# --------------------------------------------------------------------------- + +_QUERY_REGISTRY: dict[str, dict[str, QueryDef]] = {} +_ACTION_REGISTRY: dict[str, dict[str, ActionDef]] = {} + + +def register_query(service: str, qdef: QueryDef) -> None: + if service not in _QUERY_REGISTRY: + _QUERY_REGISTRY[service] = {} + _QUERY_REGISTRY[service][qdef.name] = qdef + logger.debug("Registered query %s:%s", service, qdef.name) + + +def register_action(service: str, adef: ActionDef) -> None: + if service not in _ACTION_REGISTRY: + _ACTION_REGISTRY[service] = {} + _ACTION_REGISTRY[service][adef.name] = adef + logger.debug("Registered action %s:%s", service, adef.name) + + +def get_query(service: str, name: str) -> QueryDef | None: + return _QUERY_REGISTRY.get(service, {}).get(name) + + +def get_action(service: str, name: str) -> ActionDef | None: + return _ACTION_REGISTRY.get(service, {}).get(name) + + +def get_all_queries(service: str) -> dict[str, QueryDef]: + return dict(_QUERY_REGISTRY.get(service, {})) + + +def get_all_actions(service: str) -> dict[str, ActionDef]: + return dict(_ACTION_REGISTRY.get(service, {})) + + +def clear(service: str | None = None) -> None: + if service is None: + _QUERY_REGISTRY.clear() + _ACTION_REGISTRY.clear() + else: + _QUERY_REGISTRY.pop(service, None) + _ACTION_REGISTRY.pop(service, None) + + +# --------------------------------------------------------------------------- +# Loading — parse .sx files and collect QueryDef / ActionDef instances +# --------------------------------------------------------------------------- + +def load_query_file(filepath: str, service_name: str) -> list[QueryDef]: + """Parse an .sx file and register any defquery definitions.""" + from .parser import parse_all + from .evaluator import _eval + from .jinja_bridge import get_component_env + + with open(filepath, encoding="utf-8") as f: + source = f.read() + + env = dict(get_component_env()) + exprs = parse_all(source) + queries: list[QueryDef] = [] + + for expr in exprs: + _eval(expr, env) + + for val in env.values(): + if isinstance(val, QueryDef): + register_query(service_name, val) + queries.append(val) + + return queries + + +def load_action_file(filepath: str, service_name: str) -> list[ActionDef]: + """Parse an .sx file and register any defaction definitions.""" + from .parser import parse_all + from .evaluator import _eval + from .jinja_bridge import get_component_env + + with open(filepath, encoding="utf-8") as f: + source = f.read() + + env = dict(get_component_env()) + exprs = parse_all(source) + actions: list[ActionDef] = [] + + for expr in exprs: + _eval(expr, env) + + for val in env.values(): + if isinstance(val, ActionDef): + register_action(service_name, val) + actions.append(val) + + return actions + + +def load_query_dir(directory: str, service_name: str) -> list[QueryDef]: + """Load all .sx files from a directory and register queries.""" + import glob as glob_mod + queries: list[QueryDef] = [] + for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))): + queries.extend(load_query_file(filepath, service_name)) + return queries + + +def load_action_dir(directory: str, service_name: str) -> list[ActionDef]: + """Load all .sx files from a directory and register actions.""" + import glob as glob_mod + actions: list[ActionDef] = [] + for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))): + actions.extend(load_action_file(filepath, service_name)) + return actions + + +def load_service_protocols(service_name: str, base_dir: str) -> None: + """Load queries.sx and actions.sx from a service's base directory.""" + queries_path = os.path.join(base_dir, "queries.sx") + actions_path = os.path.join(base_dir, "actions.sx") + if os.path.exists(queries_path): + load_query_file(queries_path, service_name) + logger.info("Loaded queries for %s from %s", service_name, queries_path) + if os.path.exists(actions_path): + load_action_file(actions_path, service_name) + logger.info("Loaded actions for %s from %s", service_name, actions_path) + + +# --------------------------------------------------------------------------- +# Schema — introspection for /internal/schema +# --------------------------------------------------------------------------- + +def schema_for_service(service: str) -> dict[str, Any]: + """Return a JSON-serializable schema of all queries and actions.""" + queries = [] + for qdef in _QUERY_REGISTRY.get(service, {}).values(): + queries.append({ + "name": qdef.name, + "params": list(qdef.params), + "doc": qdef.doc, + }) + actions = [] + for adef in _ACTION_REGISTRY.get(service, {}).values(): + actions.append({ + "name": adef.name, + "params": list(adef.params), + "doc": adef.doc, + }) + return { + "service": service, + "queries": sorted(queries, key=lambda q: q["name"]), + "actions": sorted(actions, key=lambda a: a["name"]), + } diff --git a/shared/sx/types.py b/shared/sx/types.py index 88fc99d..1613c08 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -240,9 +240,47 @@ class PageDef: return f"" +# --------------------------------------------------------------------------- +# QueryDef / ActionDef +# --------------------------------------------------------------------------- + +@dataclass +class QueryDef: + """A declarative data query defined in an .sx file. + + Created by ``(defquery name (&key param...) "docstring" body)``. + The body is evaluated with async I/O primitives to produce JSON data. + """ + name: str + params: list[str] # keyword parameter names + doc: str # docstring + body: Any # unevaluated s-expression body + closure: dict[str, Any] = field(default_factory=dict) + + def __repr__(self): + return f"" + + +@dataclass +class ActionDef: + """A declarative action defined in an .sx file. + + Created by ``(defaction name (&key param...) "docstring" body)``. + The body is evaluated with async I/O primitives to produce JSON data. + """ + name: str + params: list[str] # keyword parameter names + doc: str # docstring + body: Any # unevaluated s-expression body + closure: dict[str, Any] = field(default_factory=dict) + + def __repr__(self): + return f"" + + # --------------------------------------------------------------------------- # Type alias # --------------------------------------------------------------------------- # An s-expression value after evaluation -SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | list | dict | _Nil | None +SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None