"""Minimal HTTP layer for the per-tenant HUX service. Stdlib only. A ``Router`` maps method + path template to a handler; family modules register their routes with it. Every request resolves identity from trusted headers, checks the owning card's flag, and writes an audit outcome. Errors always leave as ``hux.error.v1``. """ from __future__ import annotations import json import re from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable from collections.abc import Mapping from urllib.parse import parse_qs, urlsplit from hux import audit from hux.errors import HuxError, Invalid, NotFound, TooLarge from hux.flags import Flags, build_from_environ from hux.identity import Identity, resolve from hux.store import TenantStore MAX_BODY_BYTES = 1024 * 1024 Handler = Callable[["Request"], "Response"] @dataclass class Request: """Everything a handler needs; no raw socket access.""" method: str path: str params: dict[str, str] query: dict[str, str] headers: Mapping[str, str] body: Any identity: Identity store: TenantStore flags: Flags flags_build: dict[str, str] = field(default_factory=dict) def if_match(self) -> int | None: """Parsed If-Match revision, or None when absent.""" raw = self.header("If-Match") if raw == "": return None if not raw.isdigit(): raise Invalid("If-Match must be a revision integer") return int(raw) def idempotency_key(self) -> str: """Client idempotency key, validated against the contract pattern.""" raw = self.header("Idempotency-Key") if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw): raise Invalid("malformed Idempotency-Key") return raw def header(self, name: str) -> str: """Case-insensitive header lookup.""" for key, value in self.headers.items(): if key.lower() == name.lower(): return value.strip() return "" def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None: """Write an audit outcome for this request.""" audit.record(self.store, self.identity, action, resource, outcome, reason) @dataclass class Response: """JSON (or SSE) response.""" status: int = 200 body: Any = None headers: dict[str, str] = field(default_factory=dict) stream: Callable[[], Any] | None = None @dataclass class Route: """One registered handler.""" method: str template: str card: str action: str handler: Handler pattern: re.Pattern = field(init=False) def __post_init__(self) -> None: regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template) self.pattern = re.compile(f"^{regex}$") class Router: """Route table plus the request pipeline.""" def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None: self.data_root = Path(data_root) self.environ = environ self.flags = Flags(environ) self.build = build_from_environ(environ) self.routes: list[Route] = [] def add(self, method: str, template: str, card: str, action: str, handler: Handler) -> None: """Register a handler; ``action`` is the audit action name (family.verb).""" self.routes.append(Route(method, template, card, action, handler)) def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]: """Return (route, params, path_known).""" known = False for route in self.routes: found = route.pattern.match(path) if found: known = True if route.method == method: return route, found.groupdict(), True return None, {}, known def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response: """Run the full pipeline and never raise.""" parts = urlsplit(raw_path) query = {k: v[-1] for k, v in parse_qs(parts.query).items()} try: identity = resolve(headers, self.environ) store = TenantStore(self.data_root, identity) except HuxError as error: return Response(error.status, error.record()) route, params, known = self.match(method, parts.path) if route is None: error = Invalid("method not allowed") if known else NotFound("no such route") audit.record(store, identity, "http.route", parts.path, "not_found", error.message) return Response(405 if known else 404, error.record()) try: self.flags.require(route.card) payload = self._decode(body) request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build) response = route.handler(request) except HuxError as error: outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny") audit.record(store, identity, route.action, parts.path, outcome, error.message) return Response(error.status, error.record()) return response @staticmethod def _decode(body: bytes) -> Any: if not body: return None if len(body) > MAX_BODY_BYTES: raise TooLarge("body exceeds 1 MiB") try: return json.loads(body) except json.JSONDecodeError as error: raise Invalid(f"body is not JSON: {error.msg}") from error def page(items: list[Any], next_cursor: Any = None) -> Response: """Standard list envelope.""" return Response(200, {"items": items, "next": next_cursor}) def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: """Bind a Router to a BaseHTTPRequestHandler subclass.""" class HuxHandler(BaseHTTPRequestHandler): server_version = "hux-foundation/1.0" def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102 - quiet by design; audit ledger is the log return def _run(self) -> None: if self.path == "/healthz": self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"})) return length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else b"" self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body)) def _send(self, response: Response) -> None: if response.stream is not None: self.send_response(response.status) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() for chunk in response.stream(): self.wfile.write(chunk) self.wfile.flush() return data = json.dumps(response.body, sort_keys=True).encode() self.send_response(response.status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() self.wfile.write(data) do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run return HuxHandler def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer: """Create (but do not start) the server; callers call serve_forever().""" server = ThreadingHTTPServer((host, port), make_handler(router)) server.daemon_threads = True return server