"""Shared API response helpers + daemon-proxy route factory. Used by all API blueprints to produce consistent JSON responses per the API response contract (``{"ok": true, "data": }`` / ``{"ok": false, "error": "msg"}``) and to collapse the repetitive ``try: _ok(verb(EP, body)) except -> `` boilerplate into a single declarative ``daemon_route`` decorator. The factory dispatches to the ``daemon.client`` verb imported into the blueprint's own module namespace (resolved via ``sys.modules`` at request time) so that tests can patch ``webui.api..{get,post,patch,delete}``. """ from __future__ import annotations import logging from collections.abc import Callable from typing import Any from flask import Blueprint, jsonify, request from daemon.client import BadRequest, Conflict, NotFound from daemon.iface import Endpoint logger = logging.getLogger(__name__) # Sentinel: send the verb with NO body argument (``verb(endpoint)``). NO_BODY = object() Verb = Callable[..., Any] def _ok(data: Any = None): """Return a success JSON response.""" return jsonify({"ok": True, "data": data}) def _error(msg: str, code: int = 400): """Return an error JSON response with the given HTTP status code.""" return jsonify({"ok": False, "error": msg}), code def _derive_rule(path: str) -> str: """Derive the Flask rule (relative to the blueprint url_prefix) from a daemon endpoint path by dropping the leading subsystem segment. ``/firewall/zones`` -> ``/zones``; ``/acme/issue/status`` -> ``/issue/status``. """ parts = path.lstrip("/").split("/") if len(parts) <= 1: return "/" return "/" + "/".join(parts[1:]) def _map_view_args( view_args: dict[str, Any], params: dict[str, str] | None ) -> dict[str, Any]: """Map Flask view args onto daemon body keys. ``params`` is a ``{body_key: view_arg_name}`` rename table. Any view arg not listed maps to itself (identity), so path params are always forwarded and only renamed where the daemon expects a different key. """ result = dict(view_args) for body_key, view_arg_name in (params or {}).items(): result.pop(view_arg_name, None) result[body_key] = view_args[view_arg_name] return result def daemon_route( endpoint: Endpoint, bp: Blueprint, rule: str | None = None, methods: tuple[str, ...] | list[str] | None = None, *, params: dict[str, str] | None = None, precheck: Callable[[Any, dict[str, Any]], None] | None = None, body: Any | None = None, transform: Callable[[Any, dict[str, Any], Any], Any] | None = None, ) -> Callable[..., Any]: """Decorator factory for thin daemon-proxy routes. Args: endpoint: ``daemon.iface`` ``(method, path)`` tuple; ``endpoint[0]`` is the daemon HTTP verb. bp: The target blueprint. rule: Flask rule relative to the blueprint ``url_prefix``. Defaults to the endpoint path minus its leading subsystem segment. methods: Flask HTTP method(s). Defaults to ``[endpoint[0]]``; override where the UI verb differs from the daemon verb (e.g. a UI ``PUT`` that maps to a daemon ``POST``). params: ``{body_key: view_arg_name}`` renames for path params. precheck: ``(json, view_args) -> None`` run before dispatch; raise ``ValueError``/``BadRequest`` for a 400 (preserves webui-side validation the daemon does not perform). ``json`` is the raw ``request.get_json(silent=True)`` result. body: How to build the daemon request body for non-GET verbs. ``None`` (default): ``{**json, **mapped_view_args}``; ``NO_BODY``: send no body argument; a callable ``(request, view_args) -> dict``: custom body (raise ``ValueError`` for 400); a ``dict``: fixed body (merged with mapped view args). transform: ``(data, view_args, sent_body) -> data`` applied to the daemon result before wrapping in ``_ok()``; may raise a typed exception to emit an error (e.g. 400 when a result is invalid). Returns: A decorator that registers the route and returns a view function whose ``__name__``/``__doc__`` are inherited from the decorated function. """ if rule is None: rule = _derive_rule(endpoint[1]) if methods is None: methods = [endpoint[0]] daemon_method = endpoint[0] def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: # The decorated view lives in the blueprint's module; its ``__globals__`` # is that module's namespace. Resolving the daemon verb here (instead of # ``daemon.client`` directly) means tests patching # ``webui.api..{get,post,patch,delete}`` intercept the dispatch. module_globals = fn.__globals__ def view(**view_args: Any) -> Any: try: json: Any = request.get_json(silent=True) if precheck is not None: precheck(json, view_args) mapped = _map_view_args(view_args, params) verb_fn: Verb = module_globals[daemon_method.lower()] if daemon_method == "GET": sent_body = mapped data = ( verb_fn(endpoint, sent_body) if sent_body else verb_fn(endpoint) ) elif body is NO_BODY: sent_body = None data = verb_fn(endpoint) elif callable(body): built = body(request, view_args) sent_body = built data = ( verb_fn(endpoint, built) if built is not None else verb_fn(endpoint) ) elif isinstance(body, dict): sent_body = {**body, **mapped} data = verb_fn(endpoint, sent_body) else: base = json if isinstance(json, dict) else {} sent_body = {**base, **mapped} data = verb_fn(endpoint, sent_body) if transform is not None: data = transform(data, view_args, sent_body) return _ok(data) except (BadRequest, ValueError) as exc: return _error(str(exc), 400) except NotFound as exc: logger.info("daemon 404 for %s %s: %s", daemon_method, endpoint[1], exc) return _error(str(exc), 404) except Conflict as exc: logger.info("daemon 409 for %s %s: %s", daemon_method, endpoint[1], exc) return _error(str(exc), 409) except RuntimeError as exc: logger.error( "daemon error for %s %s: %s", daemon_method, endpoint[1], exc ) return _error(str(exc), 500) view.__name__ = fn.__name__ view.__doc__ = fn.__doc__ bp.add_url_rule(rule, view_func=view, methods=list(methods)) return view return decorator def require_dict_body(json: Any, _view_args: dict[str, Any]) -> None: """Precheck: reject a non-dict JSON body (400). A missing body (``None``) is tolerated and becomes ``{}`` downstream. """ if json is not None and not isinstance(json, dict): raise ValueError("Request body must be a JSON object") def void_transform(_data: Any, _view_args: dict[str, Any], _sent: Any) -> None: """Transform: discard the daemon result and return ``data: null``. Matches routes that historically responded ``_ok(None)`` (the daemon result was intentionally ignored by the caller). """ return None