Coverage for src/lexigram/web/middleware/adapter.py: 0%
76 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Middleware Adapter - Bridge Lexigram to Starlette"""
3from __future__ import annotations
5from typing import Any, cast
7from starlette.requests import Request as StarletteRequest
8from starlette.responses import (
9 HTMLResponse as StarletteHTMLResponse,
10)
11from starlette.responses import (
12 Response as StarletteResponse,
13)
14from starlette.types import ASGIApp, Receive, Scope, Send
16from lexigram.web.transport.responses import Response as WebResponse
19class _LexigramMiddlewareAdapter:
20 """Adapter that bridges Lexigram's Middleware protocol to Starlette's ASGI pipeline"""
22 def __init__(self, app: ASGIApp, lexigram_mw: Any):
23 self.app = app
24 self.lexigram_mw = lexigram_mw
26 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
27 """Pure ASGI implementation to support HTTP and WebSocket (pass-through)"""
28 if scope["type"] != "http":
29 # Pass through non-HTTP requests (WebSocket, lifespan, etc.)
30 await self.app(scope, receive, send)
31 return
33 # 1. Expose the Starlette request to Lexigram middleware
34 request = StarletteRequest(scope, receive)
36 if callable(self.lexigram_mw):
37 # Support for functional middleware: async def mw(request, call_next)
38 response_started = [False]
40 async def lexigram_call_next(req: StarletteRequest) -> Any:
41 async def send_wrapper(message: Any) -> None:
42 if message["type"] == "http.response.start":
43 response_started[0] = True
44 await send(message)
46 await self.app(scope, receive, send_wrapper)
47 return None
49 result = await cast("Any", self.lexigram_mw)(request, lexigram_call_next)
50 # Only send result if the response hasn't been sent already.
51 # If the inner app sent an error response and re-raised, a
52 # functional middleware may catch and return a new response.
53 # Sending it would cause a double-response RuntimeError.
54 if result is not None and not response_started[0]:
55 if hasattr(result, "__call__"):
56 await result(scope, receive, send)
57 else:
58 response = self._convert_to_starlette_response(result)
59 await response(scope, receive, send)
60 return
61 # 3. Lifecycle pattern: Execute Lexigram middleware pre-processing
62 if hasattr(self.lexigram_mw, "process_request"):
63 await self.lexigram_mw.process_request(request)
65 # 4. Call next in ASGI pipeline
66 # We wrap 'send' to intercept for post-processing if needed
67 if hasattr(self.lexigram_mw, "process_response"):
69 async def send_wrapper(message: Any) -> None:
70 if message["type"] == "http.response.start":
71 # We could potentially modify headers here
72 pass
73 await send(message)
75 await self.app(scope, receive, send_wrapper)
77 # Post-processing usually happens via Response object in Lexigram
78 # For pure ASGI, we'd need to capture the body if we want to modify it.
79 # Piccolina doesn't currently use body-modifying middleware via this adapter.
80 else:
81 await self.app(scope, receive, send)
83 def _convert_to_starlette_response(self, response: Any) -> StarletteResponse:
84 """Convert Lexigram Response back to Starlette Response (Legacy)"""
85 # Kept for compatibility if needed, but pure ASGI uses send()
86 if isinstance(response, StarletteResponse) and not isinstance(
87 response,
88 WebResponse,
89 ):
90 return response
92 # Already a StarletteResponse subclass? Check if it's Lexigram's wrapper
93 lexigram_response = response
95 headers = dict(lexigram_response.headers or {})
96 content = getattr(lexigram_response, "body", None)
97 if content is None:
98 # Try to get from Starlette-style property if it's a wrapper
99 content = getattr(lexigram_response, "_content", b"")
101 ct = headers.get("content-type") or headers.get("Content-Type", "")
102 ct = (ct or "").lower()
104 # If content is already bytes, use raw StarletteResponse
105 if isinstance(content, bytes):
106 return StarletteResponse(
107 content=content,
108 status_code=lexigram_response.status_code,
109 headers=headers,
110 )
112 if "text/html" in ct:
113 return StarletteHTMLResponse(
114 content=content,
115 status_code=lexigram_response.status_code,
116 headers=headers,
117 )
118 if "application/json" in ct:
119 from lexigram.web.transport.responses import JSONResponse as WebJSONResponse
121 return cast(
122 "StarletteResponse",
123 WebJSONResponse(
124 content=content,
125 status_code=lexigram_response.status_code,
126 headers=headers,
127 ),
128 )
129 return StarletteResponse(
130 content=content,
131 status_code=lexigram_response.status_code,
132 headers=headers,
133 )
136class _SimpleASGIMiddlewareAdapter:
137 """Adapter to convert transport-agnostic middleware to ASGI format.
139 Relocated from lexigram core.
140 """
142 def __init__(self, middleware: Any) -> None:
143 self.middleware = middleware
145 async def __call__(
146 self,
147 scope: dict[str, Any],
148 receive: Any,
149 send: Any,
150 ) -> None:
151 """ASGI-compatible call."""
152 result: dict[str, Any] = {}
154 async def call_next() -> Any:
155 async def asgi_handler() -> None:
156 await send(result)
158 return await self._run_inner(asgi_handler)
160 try:
161 await cast("Any", self.middleware)(scope, call_next)
162 except Exception as e: # noqa: BLE001 — ASGI middleware adapter must capture any middleware error to record it before re-raising
163 result["error"] = str(e)
164 raise
166 async def _run_inner(self, handler: Any) -> None:
167 await handler()
170__all__ = ["_LexigramMiddlewareAdapter", "_SimpleASGIMiddlewareAdapter"]