Refactor to composable KayaMixin architecture

Replace wrapper-based SessionMiddleware/OIDCApp with KayaMixin subclasses
applied via KayaApp(mixins=[...]). Mixins hook into handle_request and
handle_websocket via before/after hooks, so both ASGI and RSGI keep working.
Mixin dependencies are applied automatically and deduplicated.
This commit is contained in:
2026-07-18 16:39:34 +00:00
parent 99e25bd8f1
commit 49c63eacc8
14 changed files with 480 additions and 359 deletions
@@ -1,6 +1,7 @@
from ._app import AbstractKayaApp, KayaApp
from ._http_method import HttpMethod
from ._http_context import HttpContext
from ._mixin import KayaMixin
from ._tree import Tree, PathIterator
from ._path_handler import PathHandler, Matches
from ._websocket import WebSocket, WebSocketMessage
@@ -10,6 +11,7 @@ __all__ = [
'AbstractKayaApp',
'HttpMethod',
'KayaApp',
'KayaMixin',
'HttpContext',
'Tree',
'PathHandler',
+78 -14
View File
@@ -6,9 +6,10 @@ from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional, Tuple,
from pwo import Maybe, AsyncQueueIterator
from ._http_context import HttpContext
from ._http_method import HttpMethod
from ._mixin import KayaMixin
from ._path_handler import Context
from ._types import StrOrStrings
from ._websocket import WebSocket
from ._websocket import WebSocket, WebSocketMessage
from ._asgi import AsgiContext, AsgiWebSocket
from ._tree import Tree
from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketScope as ASGIWebSocketScope
@@ -18,6 +19,10 @@ log = getLogger(__name__)
type HttpHandler = Callable[[HttpContext, Unpack[Any]], Awaitable[None]]
type WebSocketHandler = Callable[[WebSocket, Unpack[Any]], Awaitable[None]]
type BeforeRequestHook = Callable[[HttpContext], Awaitable[Optional[HttpContext]]]
type AfterRequestHook = Callable[[HttpContext], Awaitable[None]]
type BeforeWebSocketHook = Callable[[WebSocket], Awaitable[Optional[WebSocket]]]
type AfterWebSocketHook = Callable[[WebSocket], Awaitable[None]]
class AbstractKayaApp(ABC):
@@ -89,25 +94,84 @@ class AbstractKayaApp(ABC):
class KayaApp(AbstractKayaApp):
_tree: Tree
_mixins: list[KayaMixin]
_applied_ids: set[int]
_before_request_hooks: list[BeforeRequestHook]
_after_request_hooks: list[AfterRequestHook]
_before_websocket_hooks: list[BeforeWebSocketHook]
_after_websocket_hooks: list[AfterWebSocketHook]
def __init__(self) -> None:
def __init__(self, mixins: Sequence[KayaMixin] = ()) -> None:
self._tree = Tree()
self._mixins = []
self._applied_ids = set()
self._before_request_hooks = []
self._after_request_hooks = []
self._before_websocket_hooks = []
self._after_websocket_hooks = []
for mixin in mixins:
self._apply_mixin(mixin)
def _apply_mixin(self, mixin: KayaMixin) -> None:
if id(mixin) in self._applied_ids:
return
for dependency in mixin.dependencies:
self._apply_mixin(dependency)
mixin.apply(self)
self._applied_ids.add(id(mixin))
self._mixins.append(mixin)
def add_before_request_hook(self, hook: BeforeRequestHook) -> None:
self._before_request_hooks.append(hook)
def add_after_request_hook(self, hook: AfterRequestHook) -> None:
self._after_request_hooks.append(hook)
def add_before_websocket_hook(self, hook: BeforeWebSocketHook) -> None:
self._before_websocket_hooks.append(hook)
def add_after_websocket_hook(self, hook: AfterWebSocketHook) -> None:
self._after_websocket_hooks.append(hook)
async def handle_request(self, ctx: HttpContext) -> None:
result = self._tree.get_handler(ctx.path, ctx.method)
if result is not None:
handler, captured = result
await handler.handle_request(ctx, captured)
else:
await ctx.send_empty(404)
for hook in self._before_request_hooks:
new_ctx = await hook(ctx)
if new_ctx is not None:
ctx = new_ctx
try:
result = self._tree.get_handler(ctx.path, ctx.method)
if result is not None:
handler, captured = result
await handler.handle_request(ctx, captured)
else:
await ctx.send_empty(404)
finally:
for hook in reversed(self._after_request_hooks):
await hook(ctx)
async def handle_websocket(self, ws: WebSocket) -> None:
result = self._tree.get_handler(ws.path, HttpMethod.WS)
if result is not None:
handler, captured = result
await handler.handle_request(ws, captured)
else:
await ws.close(1000)
for hook in self._before_websocket_hooks:
new_ws = await hook(ws)
if new_ws is not None:
ws = new_ws
try:
result = self._tree.get_handler(ws.path, HttpMethod.WS)
if result is not None:
handler, captured = result
await handler.handle_request(ws, captured)
else:
await ws.close(1000)
finally:
for hook in reversed(self._after_websocket_hooks):
await hook(ws)
def setup(self, loop: AbstractEventLoop) -> None:
for mixin in self._mixins:
mixin.setup(loop)
def shutdown(self, loop: AbstractEventLoop) -> None:
for mixin in self._mixins:
mixin.shutdown(loop)
def route(self,
paths: StrOrStrings,
@@ -0,0 +1,37 @@
from abc import ABC, abstractmethod
from asyncio import AbstractEventLoop
from typing import TYPE_CHECKING, Sequence
if TYPE_CHECKING:
from ._app import KayaApp
class KayaMixin(ABC):
"""Base class for composable Kaya app extensions.
A mixin modifies a ``KayaApp`` instance in place by registering routes,
adding request/websocket hooks, or exposing helper methods on the mixin
instance itself. Because the app remains a ``KayaApp``, both ASGI and RSGI
protocols keep working regardless of which mixins are applied.
Mixins may declare other mixins they depend on via ``dependencies``. The
``KayaApp`` constructor applies dependencies first and guarantees each
mixin is applied at most once.
"""
@property
def dependencies(self) -> Sequence['KayaMixin']:
return ()
@abstractmethod
def apply(self, app: 'KayaApp') -> None:
"""Configure the app: register routes, add hooks, etc."""
pass
def setup(self, loop: AbstractEventLoop) -> None:
"""Called on lifespan startup (default: no-op)."""
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
"""Called on lifespan shutdown (default: no-op)."""
pass