v1
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Request logging middleware.
|
||||
|
||||
Prints a clean, colour-coded line for every meaningful HTTP request.
|
||||
Context-aware: pulls the email, member status, service type, etc. from the
|
||||
request body for the most important endpoints so you can read the log without
|
||||
needing to replay the request.
|
||||
|
||||
Format
|
||||
------
|
||||
METHOD /path/to/endpoint STATUS timing origin
|
||||
↳ human-readable context (when relevant)
|
||||
|
||||
Localhost / loopback addresses are rendered as local:PORT rather than the
|
||||
raw IP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
# Force UTF-8 on Windows so arrow / symbol characters render correctly in any
|
||||
# terminal (Windows Terminal, VS Code, PowerShell). On other platforms the
|
||||
# default encoding is already UTF-8.
|
||||
def _make_console() -> Console:
|
||||
if sys.platform == "win32" and hasattr(sys.stdout, "buffer"):
|
||||
out = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
||||
return Console(highlight=False, markup=True, file=out)
|
||||
return Console(highlight=False, markup=True)
|
||||
|
||||
_console = _make_console()
|
||||
|
||||
# ── Paths that are too noisy to log ───────────────────────────────────────────
|
||||
|
||||
_SKIP = frozenset({"/health", "/favicon.ico", "/robots.txt"})
|
||||
_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
# ── Colour maps ───────────────────────────────────────────────────────────────
|
||||
|
||||
_METHOD_STYLE: dict[str, str] = {
|
||||
"GET": "bold #6ea8fe", # soft blue
|
||||
"POST": "bold #75b798", # soft green
|
||||
"PUT": "bold #e6a817", # amber
|
||||
"PATCH": "bold #c586c0", # lavender
|
||||
"DELETE": "bold #f28b82", # soft red
|
||||
"HEAD": "dim",
|
||||
"OPTIONS": "dim",
|
||||
}
|
||||
|
||||
|
||||
def _status_style(code: int) -> str:
|
||||
if code < 300:
|
||||
return "bold green"
|
||||
if code < 400:
|
||||
return "bold cyan"
|
||||
if code < 500:
|
||||
return "bold yellow"
|
||||
return "bold red"
|
||||
|
||||
|
||||
def _timing_style(ms: float) -> str:
|
||||
if ms < 200:
|
||||
return "white"
|
||||
if ms < 1_000:
|
||||
return "yellow"
|
||||
return "bold red"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_LOCAL_HOSTS = {"127.0.0.1", "::1", "0.0.0.0", "localhost", "::ffff:127.0.0.1"}
|
||||
|
||||
|
||||
def _origin(request: Request) -> str:
|
||||
host = request.client.host if request.client else "unknown"
|
||||
port = request.url.port or 8000
|
||||
if host in _LOCAL_HOSTS:
|
||||
return f"[dim]local:{port}[/dim]"
|
||||
return f"[dim]{host}[/dim]"
|
||||
|
||||
|
||||
def _body(raw: bytes) -> Optional[dict]:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _shorten(path: str, width: int = 58) -> str:
|
||||
"""Left-pad the path to *width* chars, truncating with ellipsis if needed."""
|
||||
if len(path) > width:
|
||||
path = path[: width - 1] + "…"
|
||||
return f"{path:<{width}}"
|
||||
|
||||
|
||||
# ── Context extraction ────────────────────────────────────────────────────────
|
||||
# Each branch returns a *markup* string (may contain [colour] tags) or None.
|
||||
|
||||
def _context(path: str, method: str, data: Optional[dict], status: int) -> Optional[str]: # noqa: C901
|
||||
d = data or {}
|
||||
em = d.get("email", "")
|
||||
|
||||
# ── Admin auth ────────────────────────────────────────────────────────────
|
||||
if path.endswith("/auth/login") and "/members/" not in path:
|
||||
if status < 400:
|
||||
return f"[dim]admin[/dim] · {em}"
|
||||
return f"[red]✗[/red] bad credentials · {em}"
|
||||
|
||||
# ── Member auth ───────────────────────────────────────────────────────────
|
||||
if "/members/auth/login/verify" in path:
|
||||
if status < 400:
|
||||
return f"[dim]member 2FA[/dim] · {em} · [green]verified ✓[/green]"
|
||||
return f"[red]✗[/red] bad 2FA code · {em}"
|
||||
|
||||
if "/members/auth/login" in path and path.endswith("/login"):
|
||||
if status < 400:
|
||||
return f"[dim]member login[/dim] · {em}"
|
||||
return f"[red]✗[/red] bad password · {em}"
|
||||
|
||||
if "/members/auth/refresh" in path:
|
||||
return None # token rotation — no useful body detail
|
||||
|
||||
# ── Claim flow ────────────────────────────────────────────────────────────
|
||||
if "/members/claim/request" in path:
|
||||
return f"[dim]claim request[/dim] · {em}"
|
||||
|
||||
if "/members/claim/complete" in path:
|
||||
if status < 400:
|
||||
return f"[green]account claimed[/green] · {em}"
|
||||
return f"[red]✗[/red] claim failed · {em}"
|
||||
|
||||
# ── Member: profile ───────────────────────────────────────────────────────
|
||||
if method == "PUT" and path.endswith("/members/me"):
|
||||
fields = [k for k in d]
|
||||
if fields:
|
||||
return "[dim]updated[/dim] · " + ", ".join(fields)
|
||||
return None
|
||||
|
||||
# ── Member: onboarding ────────────────────────────────────────────────────
|
||||
if "/members/onboarding/contract" in path:
|
||||
signer = d.get("signer_name", "")
|
||||
if status < 400:
|
||||
return f"[green]contract signed[/green] · {signer}"
|
||||
return None
|
||||
|
||||
if method == "PUT" and "/members/onboarding" in path:
|
||||
if d.get("complete_onboarding"):
|
||||
return "[dim]onboarding complete[/dim] → [yellow]pending_contract[/yellow]"
|
||||
return None
|
||||
|
||||
# ── Member: bookings ──────────────────────────────────────────────────────
|
||||
if method == "POST" and path.endswith("/members/bookings"):
|
||||
svc = d.get("service_type", "")
|
||||
notes = d.get("notes", "")
|
||||
label = _service_label(svc)
|
||||
parts = [label] + ([notes[:50]] if notes else [])
|
||||
return "[dim]booking request[/dim] · " + " · ".join(p for p in parts if p)
|
||||
|
||||
# ── Admin: create member ──────────────────────────────────────────────────
|
||||
if (
|
||||
method == "POST"
|
||||
and "/admin/members" in path
|
||||
and not any(seg in path for seg in ("/activate", "/walks", "/bookings", "/messages"))
|
||||
):
|
||||
first = d.get("first_name", "")
|
||||
last = d.get("last_name", "")
|
||||
em2 = d.get("email", "")
|
||||
name = f"{first} {last}".strip()
|
||||
parts = [n for n in (name, em2) if n]
|
||||
return "[dim]new member[/dim] · " + " · ".join(parts)
|
||||
|
||||
if method == "POST" and "/admin/members/" in path and path.endswith("/activate"):
|
||||
return "status → [green]active ✓[/green]"
|
||||
|
||||
if method == "PUT" and "/admin/members/" in path:
|
||||
s = d.get("member_status")
|
||||
if s:
|
||||
return f"status → [cyan]{s}[/cyan]"
|
||||
return None
|
||||
|
||||
# ── Admin: walks ──────────────────────────────────────────────────────────
|
||||
if method == "POST" and "/admin/walks" in path:
|
||||
svc = _service_label(d.get("service_type", ""))
|
||||
dur = d.get("duration_minutes", "")
|
||||
parts = [svc] + ([f"{dur} min"] if dur else [])
|
||||
return "[dim]walk recorded[/dim] · " + " · ".join(p for p in parts if p)
|
||||
|
||||
# ── Admin: messages ───────────────────────────────────────────────────────
|
||||
if method == "POST" and "/admin/messages" in path:
|
||||
subject = d.get("subject", "")
|
||||
return f"[dim]message sent[/dim] · {subject}" if subject else "[dim]message sent[/dim]"
|
||||
|
||||
# ── Admin: bookings ───────────────────────────────────────────────────────
|
||||
if method == "PUT" and "/admin/bookings/" in path:
|
||||
s = d.get("status")
|
||||
if s:
|
||||
_colour = {"confirmed": "green", "cancelled": "red", "completed": "cyan"}.get(s, "yellow")
|
||||
return f"status → [{_colour}]{s}[/{_colour}]"
|
||||
if d.get("admin_notes"):
|
||||
return "[dim]admin notes updated[/dim]"
|
||||
return None
|
||||
|
||||
# ── Admin: notifications ──────────────────────────────────────────────────
|
||||
if method == "POST" and "/admin/notifications/run" in path:
|
||||
return "[dim]notification run triggered[/dim]"
|
||||
|
||||
if method == "PUT" and "/admin/notifications/settings" in path:
|
||||
keys = list(d.keys())
|
||||
return "[dim]settings updated[/dim] · " + ", ".join(keys) if keys else None
|
||||
|
||||
# ── Contact leads ─────────────────────────────────────────────────────────
|
||||
if "/contact" in path and method == "POST":
|
||||
name = d.get("full_name") or d.get("name", "")
|
||||
pet = d.get("pet_name", "")
|
||||
email_fallback = d.get("email", "")
|
||||
parts = [name or email_fallback] + ([f"dog: {pet}"] if pet else [])
|
||||
return "[dim]lead[/dim] · " + " · ".join(p for p in parts if p)
|
||||
|
||||
# ── Generic 4xx hints ─────────────────────────────────────────────────────
|
||||
if status == 401:
|
||||
return "[red]✗[/red] unauthorized"
|
||||
if status == 403:
|
||||
return "[red]✗[/red] forbidden"
|
||||
if status == 422:
|
||||
return "[yellow]⚠[/yellow] validation error"
|
||||
if status == 429:
|
||||
return "[yellow]⚠[/yellow] rate limited"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _service_label(svc: str) -> str:
|
||||
return {"pack_walk": "Pack Walk", "1_1_walk": "1-1 Walk", "puppy_visit": "Puppy Visit"}.get(svc, svc)
|
||||
|
||||
|
||||
# ── Middleware ────────────────────────────────────────────────────────────────
|
||||
|
||||
class RequestLogMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Logs every non-trivial HTTP request to the console via Rich.
|
||||
|
||||
Body reads are cached by Starlette's Request.body() so downstream
|
||||
handlers always see the full body unchanged.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
if path in _SKIP or method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
# Read and cache body before handing to the route handler.
|
||||
raw = b""
|
||||
if method in _BODY_METHODS:
|
||||
raw = await request.body() # Starlette caches in request._body
|
||||
|
||||
data = _body(raw)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
elapsed = (time.perf_counter() - t0) * 1_000
|
||||
|
||||
status = response.status_code
|
||||
ctx = _context(path, method, data, status)
|
||||
origin = _origin(request)
|
||||
|
||||
method_w = f"{method:<7}"
|
||||
timing = f"{elapsed:>7.1f}ms"
|
||||
path_w = _shorten(path)
|
||||
|
||||
method_styled = f"[{_METHOD_STYLE.get(method, 'white')}]{method_w}[/]"
|
||||
status_styled = f"[{_status_style(status)}]{status}[/]"
|
||||
timing_styled = f"[{_timing_style(elapsed)}]{timing}[/]"
|
||||
|
||||
_console.print(
|
||||
f" {method_styled} {path_w} {status_styled} {timing_styled} {origin}"
|
||||
)
|
||||
if ctx:
|
||||
_console.print(f" [dim]↳[/dim] {ctx}")
|
||||
|
||||
return response
|
||||
Reference in New Issue
Block a user