Skip to content

Commit c130578

Browse files
fsecada01claude
andcommitted
feat: add Litestar adapter with HTTP endpoint, WebSocket, tests, and example
Closes #6 — first-class Litestar adapter following the existing FastAPI pattern: - adapters/litestar.py: component_endpoint + create_component_routes - adapters/litestar_websocket.py: LitestarWebSocketConnection + endpoint - tests/test_litestar_adapter.py: 8 tests (mount, events, errors, routes) - examples/litestar_example.py: demo app with Jinja2 renderer - pyproject.toml: [litestar] optional extras group, updated [all] and [dev] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a1ee55 commit c130578

5 files changed

Lines changed: 409 additions & 3 deletions

File tree

examples/litestar_example.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Litestar example application with Counter component."""
2+
3+
import sys
4+
from pathlib import Path
5+
6+
# Add src to path
7+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
8+
9+
from litestar import Litestar, get
10+
from litestar.response import Response
11+
12+
from component_framework.adapters.litestar import component_endpoint
13+
from component_framework.components.counter import Counter
14+
from component_framework.core import Component, Renderer
15+
16+
17+
class Jinja2Renderer(Renderer):
18+
"""Simple Jinja2 renderer for the example."""
19+
20+
def __init__(self, templates_dir: Path):
21+
from jinja2 import Environment, FileSystemLoader
22+
23+
self.env = Environment(loader=FileSystemLoader(str(templates_dir)), autoescape=True)
24+
25+
def render(self, template_name: str, context: dict) -> str:
26+
template = self.env.get_template(template_name)
27+
return template.render(**context)
28+
29+
30+
# Configure renderer globally for all components
31+
templates_dir = Path(__file__).parent.parent / "templates" / "components"
32+
renderer = Jinja2Renderer(templates_dir)
33+
Component.renderer = renderer
34+
35+
36+
@get("/")
37+
async def index() -> Response:
38+
"""Serve demo page."""
39+
counter = Counter(initial=0)
40+
result = counter.dispatch()
41+
42+
html = f"""
43+
<!DOCTYPE html>
44+
<html>
45+
<head>
46+
<title>Component Framework Demo (Litestar)</title>
47+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
48+
<style>
49+
body {{
50+
font-family: system-ui, -apple-system, sans-serif;
51+
max-width: 800px;
52+
margin: 50px auto;
53+
padding: 20px;
54+
}}
55+
h1 {{
56+
text-align: center;
57+
color: #333;
58+
}}
59+
.info {{
60+
background: #e3f2fd;
61+
padding: 15px;
62+
border-radius: 8px;
63+
margin: 20px 0;
64+
}}
65+
</style>
66+
</head>
67+
<body>
68+
<h1>Component Framework Demo (Litestar)</h1>
69+
70+
<div class="info">
71+
<h3>How it works:</h3>
72+
<ul>
73+
<li>Server-side components with state management</li>
74+
<li>HTMX for dynamic updates</li>
75+
<li>No JavaScript framework required</li>
76+
<li>Click the buttons to interact!</li>
77+
</ul>
78+
</div>
79+
80+
{result["html"]}
81+
82+
<div class="info" style="margin-top: 30px;">
83+
<h3>Component State:</h3>
84+
<pre id="state-display">{result["state"]}</pre>
85+
</div>
86+
</body>
87+
</html>
88+
"""
89+
90+
return Response(content=html, media_type="text/html")
91+
92+
93+
app = Litestar(route_handlers=[index, component_endpoint])
94+
95+
if __name__ == "__main__":
96+
import uvicorn
97+
98+
print("Starting Component Framework Demo (Litestar)")
99+
print("Open http://localhost:8000")
100+
uvicorn.run(app, host="0.0.0.0", port=8000)

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ license = {text = "MIT"}
88
authors = [
99
{name = "Francis Secada", email = "francis.secada@gmail.com"}
1010
]
11-
keywords = ["components", "server-components", "liveview", "htmx", "fastapi", "django"]
11+
keywords = ["components", "server-components", "liveview", "htmx", "fastapi", "django", "litestar"]
1212
classifiers = [
1313
"Development Status :: 4 - Beta",
1414
"Intended Audience :: Developers",
@@ -35,6 +35,10 @@ django = [
3535
"channels>=4.0",
3636
"channels-redis>=4.1",
3737
]
38+
litestar = [
39+
"litestar>=2.0",
40+
"jinja2>=3.1",
41+
]
3842
websockets = [
3943
"websockets>=12.0",
4044
]
@@ -47,12 +51,12 @@ dev-base = [
4751
"ty>=0.0.18",
4852
]
4953
dev = [
50-
"component-framework[dev-base,fastapi,django,websockets]",
54+
"component-framework[dev-base,fastapi,django,litestar,websockets]",
5155
"pre-commit>=3.5.0",
5256
"pdoc>=14.0",
5357
]
5458
all = [
55-
"component-framework[fastapi,django,websockets]",
59+
"component-framework[fastapi,django,litestar,websockets]",
5660
]
5761

5862
[project.urls]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Litestar adapter for component endpoints."""
2+
3+
import logging
4+
5+
try:
6+
from litestar import Request, post
7+
from litestar.exceptions import HTTPException
8+
from litestar.response import Response
9+
except ImportError as e:
10+
from . import _require_extra
11+
12+
raise _require_extra("litestar", "litestar") from e
13+
14+
from ..core import StateSerializer, registry
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
@post("/components/{name:str}")
20+
async def component_endpoint(name: str, request: Request) -> Response:
21+
"""
22+
Generic component endpoint for Litestar.
23+
24+
POST /components/{name}
25+
Body: {
26+
"event": "event_name",
27+
"payload": {...},
28+
"state": "serialized_state"
29+
}
30+
31+
Returns: {
32+
"html": "rendered_html",
33+
"state": "serialized_state",
34+
"component_id": "component-id"
35+
}
36+
"""
37+
try:
38+
# Get component class
39+
component_cls = registry.get(name)
40+
if not component_cls:
41+
raise HTTPException(status_code=404, detail=f"Component '{name}' not found")
42+
43+
# Parse request data
44+
try:
45+
data = await request.json()
46+
except Exception as e:
47+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
48+
49+
# Extract parameters
50+
params = data.get("params", {})
51+
event = data.get("event")
52+
payload = data.get("payload", {})
53+
state_str = data.get("state")
54+
55+
# Deserialize state if provided
56+
state = None
57+
if state_str:
58+
try:
59+
state = StateSerializer.deserialize(state_str)
60+
except Exception as e:
61+
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
62+
63+
# Create and dispatch component
64+
component = component_cls(**params)
65+
result = component.dispatch(event=event, payload=payload, state=state)
66+
67+
# Serialize state for response
68+
result["state"] = StateSerializer.serialize(result["state"])
69+
70+
return Response(content=result, media_type="application/json", status_code=200)
71+
72+
except HTTPException:
73+
raise
74+
except Exception:
75+
logger.exception(f"Error processing component '{name}'")
76+
raise HTTPException(status_code=500, detail="Internal server error")
77+
78+
79+
def create_component_routes(app):
80+
"""
81+
Register the component endpoint handler with a Litestar app.
82+
83+
Usage:
84+
from litestar import Litestar
85+
from component_framework.adapters.litestar import create_component_routes
86+
87+
app = Litestar(route_handlers=[])
88+
create_component_routes(app)
89+
90+
Alternatively, pass the handler directly at app creation:
91+
from component_framework.adapters.litestar import component_endpoint
92+
93+
app = Litestar(route_handlers=[component_endpoint])
94+
"""
95+
app.register(component_endpoint)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Litestar WebSocket adapter."""
2+
3+
import logging
4+
from uuid import uuid4
5+
6+
try:
7+
from litestar import WebSocket
8+
from litestar.exceptions import WebSocketDisconnect
9+
except ImportError as e:
10+
from . import _require_extra
11+
12+
raise _require_extra("litestar", "litestar") from e
13+
14+
from ..core.websocket import WebSocketConnection, ws_manager
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class LitestarWebSocketConnection(WebSocketConnection):
20+
"""Litestar WebSocket connection wrapper."""
21+
22+
def __init__(self, websocket: WebSocket):
23+
self.websocket = websocket
24+
25+
async def send(self, data: dict):
26+
"""Send JSON data to client."""
27+
await self.websocket.send_json(data)
28+
29+
async def receive(self) -> dict:
30+
"""Receive JSON data from client."""
31+
return await self.websocket.receive_json()
32+
33+
async def close(self):
34+
"""Close WebSocket connection."""
35+
await self.websocket.close()
36+
37+
38+
async def component_websocket_endpoint(websocket: WebSocket) -> None:
39+
"""
40+
Litestar WebSocket endpoint for components.
41+
42+
Usage in a Litestar app:
43+
from litestar import Litestar, websocket_listener
44+
from component_framework.adapters.litestar_websocket import (
45+
component_websocket_endpoint,
46+
)
47+
48+
@websocket_listener("/ws")
49+
async def ws_handler(websocket: WebSocket) -> None:
50+
await component_websocket_endpoint(websocket)
51+
52+
app = Litestar(route_handlers=[ws_handler])
53+
"""
54+
# Accept connection
55+
await websocket.accept()
56+
57+
# Generate connection ID
58+
connection_id = str(uuid4())
59+
connection = LitestarWebSocketConnection(websocket)
60+
61+
# Register with manager
62+
await ws_manager.connect(connection, connection_id)
63+
64+
try:
65+
# Send connection confirmation
66+
await connection.send({"type": "connected", "connection_id": connection_id})
67+
68+
# Message loop
69+
while True:
70+
data = await connection.receive()
71+
await ws_manager.handle_message(connection, connection_id, data)
72+
73+
except WebSocketDisconnect:
74+
logger.info(f"WebSocket disconnected: {connection_id}")
75+
except Exception as e:
76+
logger.exception(f"WebSocket error: {e}")
77+
finally:
78+
await ws_manager.disconnect(connection_id)

0 commit comments

Comments
 (0)