refactor: consolidate exact post dispatch

This commit is contained in:
leefer
2026-08-01 14:02:21 +08:00
parent 07e14c74b5
commit 01af7f72fa
6 changed files with 162 additions and 87 deletions
+3
View File
@@ -30,6 +30,9 @@ background scheduler
`backend/application.py` and `backend/bootstrap/`.
- `backend/http/` owns common authentication, request IDs, responses, static delivery, and
error normalization. Feature-specific transport handlers live beside their feature.
Exact POST endpoints that only delegate to one of those handlers use the explicit maps in
`backend/application.py`; endpoints with path parameters, body handling, or special error
semantics remain visible control flow in `RequestHandler`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
+43 -79
View File
@@ -507,6 +507,40 @@ class DashboardService(
SERVICE = DashboardService()
PUBLIC_POST_HANDLERS = {
"/api/auth/register": "auth_register",
"/api/auth/login": "auth_login",
}
AUTHENTICATED_POST_HANDLERS = {
"/api/auth/logout": "auth_logout",
"/api/account/birth-profile": "save_birth_profile",
"/api/account/password": "change_password",
"/api/alerts": "save_alert",
"/api/trades": "save_trade_entry",
"/api/assistant/chat": "stream_assistant_chat",
"/api/admin/settings": "save_system_settings",
"/api/admin/settings/test": "test_system_llm_settings",
"/api/admin/membership": "save_membership",
"/api/admin/refresh": "start_background_refresh",
"/api/watchlist": "save_watchlist",
"/api/notes": "save_note",
"/api/reasons": "save_reason",
"/api/seat-aliases": "save_seat_alias",
"/api/heaven/sector-phases": "save_sector_phase_override",
"/api/backfill": "backfill_data",
"/api/screener/sync": "sync_screener_data",
"/api/screener/compile": "compile_screener_strategy",
"/api/screener/strategies": "save_screener_strategy",
"/api/screener/run": "run_screener",
"/api/screener/tracking/refresh": "refresh_screener_tracking",
"/api/mentors/chat": "stream_mentor_chat",
"/api/heaven/hexagram": "heaven_hexagram",
"/api/heaven/personal": "heaven_personal",
"/api/heaven/interpret": "heaven_interpret",
}
class RequestHandler(
AccountHttpMixin,
SystemHttpMixin,
@@ -522,6 +556,13 @@ class RequestHandler(
application_service = SERVICE
route_registry = ROUTES
def _dispatch_named_handler(self, path: str, handlers: dict[str, str]) -> bool:
handler_name = handlers.get(path)
if handler_name is None:
return False
getattr(self, handler_name)()
return True
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/health":
@@ -864,24 +905,13 @@ class RequestHandler(
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/auth/register":
self.auth_register()
return
if parsed.path == "/api/auth/login":
self.auth_login()
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
return
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("POST", parsed.path):
return
if parsed.path == "/api/auth/logout":
self.auth_logout()
return
if parsed.path == "/api/account/birth-profile":
self.save_birth_profile()
return
if parsed.path == "/api/account/password":
self.change_password()
if self._dispatch_named_handler(parsed.path, AUTHENTICATED_POST_HANDLERS):
return
alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
if alert_read_match:
@@ -895,57 +925,6 @@ class RequestHandler(
{"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))}
)
return
if parsed.path == "/api/alerts":
self.save_alert()
return
if parsed.path == "/api/trades":
self.save_trade_entry()
return
if parsed.path == "/api/assistant/chat":
self.stream_assistant_chat()
return
if parsed.path == "/api/admin/settings":
self.save_system_settings()
return
if parsed.path == "/api/admin/settings/test":
self.test_system_llm_settings()
return
if parsed.path == "/api/admin/membership":
self.save_membership()
return
if parsed.path == "/api/admin/refresh":
self.start_background_refresh()
return
if parsed.path == "/api/watchlist":
self.save_watchlist()
return
if parsed.path == "/api/notes":
self.save_note()
return
if parsed.path == "/api/reasons":
self.save_reason()
return
if parsed.path == "/api/seat-aliases":
self.save_seat_alias()
return
if parsed.path == "/api/heaven/sector-phases":
self.save_sector_phase_override()
return
if parsed.path == "/api/backfill":
self.backfill_data()
return
if parsed.path == "/api/screener/sync":
self.sync_screener_data()
return
if parsed.path == "/api/screener/compile":
self.compile_screener_strategy()
return
if parsed.path == "/api/screener/strategies":
self.save_screener_strategy()
return
if parsed.path == "/api/screener/run":
self.run_screener()
return
if parsed.path == "/api/screener/tracking":
try:
result = SERVICE.add_screener_tracking(self.read_json_body())
@@ -953,9 +932,6 @@ class RequestHandler(
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
if parsed.path == "/api/mentors/preferences":
try:
result = SERVICE.save_mentor_preferences(self.read_json_body())
@@ -963,18 +939,6 @@ class RequestHandler(
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/mentors/chat":
self.stream_mentor_chat()
return
if parsed.path == "/api/heaven/hexagram":
self.heaven_hexagram()
return
if parsed.path == "/api/heaven/personal":
self.heaven_personal()
return
if parsed.path == "/api/heaven/interpret":
self.heaven_interpret()
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def do_DELETE(self) -> None:
+2 -2
View File
@@ -329,8 +329,8 @@
},
{
"path": "backend/application.py",
"bytes": 49784,
"lines": 1165
"bytes": 48749,
"lines": 1129
},
{
"path": "frontend/styles/theme.css",
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import unittest
from backend.application import (
AUTHENTICATED_POST_HANDLERS,
PUBLIC_POST_HANDLERS,
RequestHandler,
)
class HttpDispatchContractTests(unittest.TestCase):
@staticmethod
def handler(path: str, calls: list[str]) -> RequestHandler:
handler = RequestHandler.__new__(RequestHandler)
handler.path = path
handler.require_auth = lambda: calls.append("auth") or True
handler.require_csrf = lambda: calls.append("csrf") or True
handler.require_access = lambda method, route: (
calls.append(f"access:{method}:{route}") or True
)
return handler
def test_named_handlers_are_real_registered_post_routes(self) -> None:
all_handlers = {**PUBLIC_POST_HANDLERS, **AUTHENTICATED_POST_HANDLERS}
self.assertEqual(
set(PUBLIC_POST_HANDLERS) & set(AUTHENTICATED_POST_HANDLERS), set()
)
for path, handler_name in all_handlers.items():
with self.subTest(path=path):
route = RequestHandler.route_registry.resolve("POST", path)
self.assertIsNotNone(route)
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
expected_access = "public" if path in PUBLIC_POST_HANDLERS else None
if expected_access:
self.assertEqual(route.access, expected_access)
else:
self.assertNotEqual(route.access, "public")
def test_public_post_dispatches_without_authentication(self) -> None:
for path, handler_name in PUBLIC_POST_HANDLERS.items():
with self.subTest(path=path):
calls: list[str] = []
handler = self.handler(path, calls)
handler.require_auth = lambda: (_ for _ in ()).throw(
AssertionError("public route required authentication")
)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, [handler_name])
def test_authenticated_post_preserves_guard_order(self) -> None:
for path, handler_name in AUTHENTICATED_POST_HANDLERS.items():
with self.subTest(path=path):
calls: list[str] = []
handler = self.handler(path, calls)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(
calls,
["auth", "csrf", f"access:POST:{path}", handler_name],
)
def test_failed_access_never_dispatches_protected_handler(self) -> None:
path, handler_name = next(iter(AUTHENTICATED_POST_HANDLERS.items()))
calls: list[str] = []
handler = self.handler(path, calls)
handler.require_access = lambda method, route: (
calls.append(f"access:{method}:{route}") or False
)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, ["auth", "csrf", f"access:POST:{path}"])
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import ast
import json
import re
import sys
@@ -59,8 +60,32 @@ def _role(method: str, path: str) -> str:
return required_role(method, sample)
def _mapped_paths(text: str) -> dict[str, set[str]]:
paths = {method: set() for method in ("GET", "POST", "DELETE")}
tree = ast.parse(text)
for node in tree.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
match = re.fullmatch(
r"(?:PUBLIC_|AUTHENTICATED_)?(GET|POST|DELETE)_HANDLERS", target.id
)
if not match:
continue
mapping = ast.literal_eval(node.value)
if not isinstance(mapping, dict) or not all(
isinstance(path, str) and path.startswith("/api/") for path in mapping
):
raise ValueError(f"Invalid route handler map: {target.id}")
paths[match.group(1)].update(mapping)
return paths
def build() -> dict:
text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
mapped_paths = _mapped_paths(text)
method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE))
routes = []
for index, match in enumerate(method_matches):
@@ -68,6 +93,7 @@ def build() -> dict:
end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text)
block = text[match.start():end]
exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block))
exact_paths.update(mapped_paths[method])
patterns = set(
re.findall(
r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path',
+5 -6
View File
@@ -37,16 +37,15 @@ def page_inventory(html: str) -> list[dict[str, str]]:
def api_inventory(server: str) -> dict[str, list[str]]:
exact = sorted(set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', server)))
routes = json.loads(source("config/api.config.json"))["routes"]
exact = sorted(
{item["path"] for item in routes if item["match"] == "exact"}
)
prefixes = sorted(
set(re.findall(r'parsed\.path\.startswith\(\s*"(/api/[^"]+)"', server))
)
patterns = sorted(
set(
item
for item in re.findall(r'r?["\']([^"\']*?/api/[^"\']+)["\']', server)
if "\\d" in item or ".+" in item or "(?P" in item
)
{item["path"] for item in routes if item["match"] == "regex"}
)
return {"exact": exact, "prefixes": prefixes, "patterns": patterns}