Coverage for src/lexigram/admin/middleware/nav_push.py: 0%
51 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""HX-Push-Url middleware for full-page body swaps.
3htmx only updates the browser URL when the server responds with an
4``HX-Push-Url`` header (or the element carries ``hx-push-url``). Fragment
5responses (list data zones) already set this header themselves; this
6middleware covers full-page responses targeted at ``body`` so that
7client-side navigation via ``htmx.ajax(..., {target: "body"})`` keeps the
8address bar in sync and htmx history (back/forward) works.
9"""
11from __future__ import annotations
13from starlette.types import ASGIApp, Message, Receive, Scope, Send
16class AdminNavPushMiddleware:
17 """Add ``HX-Push-Url`` to HTML responses for body-targeted htmx GETs.
19 Any htmx request without an ``HX-Target`` (or with target ``body``)
20 that receives a 2xx HTML response gets an ``HX-Push-Url`` header set
21 to the request URL, so the browser address bar matches the page the
22 server rendered.
23 """
25 def __init__(self, app: ASGIApp) -> None:
26 """Initialise the middleware.
28 Args:
29 app: The next ASGI application in the stack.
30 """
31 self._app = app
33 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
34 """Process an incoming HTTP request.
36 Args:
37 scope: ASGI connection scope.
38 receive: ASGI receive callable.
39 send: ASGI send callable.
40 """
41 if scope["type"] != "http":
42 await self._app(scope, receive, send)
43 return
45 headers = scope.get("headers") or []
46 if not self._is_body_targeted_get(headers, scope["method"]):
47 await self._app(scope, receive, send)
48 return
50 push_url = self._build_push_url(scope)
51 push_url_bytes = push_url.encode("utf-8")
53 async def send_with_push(message: Message) -> None:
54 if message["type"] == "http.response.start":
55 status: int = message.get("status", 0)
56 response_headers = message.get("headers") or []
57 if 200 <= status < 300 and self._is_html(response_headers):
58 message["headers"] = [
59 *response_headers,
60 (b"hx-push-url", push_url_bytes),
61 ]
62 await send(message)
64 await self._app(scope, receive, send_with_push)
66 # ------------------------------------------------------------------
67 # Helpers
68 # ------------------------------------------------------------------
70 @classmethod
71 def _is_body_targeted_get(
72 cls, headers: list[tuple[bytes, bytes]], method: str
73 ) -> bool:
74 """Return True for htmx GET/HEAD requests targeting the body.
76 Args:
77 headers: Request headers from the ASGI scope.
78 method: Request method.
80 Returns:
81 True when the request is a GET/HEAD htmx request without a
82 specific swap target (or with target ``body``).
83 """
84 if method not in ("GET", "HEAD"):
85 return False
86 if cls._get_header(headers, b"hx-request") != b"true":
87 return False
88 target = cls._get_header(headers, b"hx-target")
89 return target is None or target == b"body"
91 @staticmethod
92 def _get_header(headers: list[tuple[bytes, bytes]], name: bytes) -> bytes | None:
93 """Return a header value, case-insensitively.
95 Args:
96 headers: Header tuples from an ASGI scope or message.
97 name: Lower-case header name to look up.
99 Returns:
100 The header value, or None when absent.
101 """
102 for key, value in headers:
103 if key.lower() == name:
104 return value
105 return None
107 @classmethod
108 def _is_html(cls, headers: list[tuple[bytes, bytes]]) -> bool:
109 """Return True when the response content type is HTML.
111 Args:
112 headers: Response header tuples from the start message.
114 Returns:
115 True when the Content-Type contains ``text/html``.
116 """
117 content_type = cls._get_header(headers, b"content-type")
118 return content_type is not None and b"text/html" in content_type
120 @staticmethod
121 def _build_push_url(scope: Scope) -> str:
122 """Build the pushable URL (path + query) for the request.
124 ``raw_path`` is preferred because Starlette's ``Mount`` rewrites
125 ``scope["path"]`` to the sub-path while ``root_path`` carries the
126 prefix, and combining them double-counts the mount prefix.
128 Args:
129 scope: ASGI connection scope.
131 Returns:
132 The full URL path including any query string.
133 """
134 raw_path = scope.get("raw_path")
135 if raw_path:
136 return raw_path.decode("latin-1")
137 root_path = scope.get("root_path", "")
138 path: str = scope["path"]
139 query: bytes = scope.get("query_string", b"")
140 query_string = query.decode("latin-1") if query else ""
141 return f"{root_path}{path}{'?' + query_string if query_string else ''}"