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-23 22:09:59 +08:00
parent 24a797e3d2
commit 3ebf079533
14 changed files with 480 additions and 359 deletions
+19 -22
View File
@@ -6,7 +6,7 @@ import httpx
from pwo import async_test
from kaya.core import KayaApp, HttpContext
from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore
from kaya.session import InMemorySessionStore, Session, SessionMixin, SessionStore
from kaya.session._cookie import format_set_cookie, parse_cookie_value
@@ -24,42 +24,40 @@ class FakeClock:
class SessionTest(unittest.TestCase):
app: KayaApp
store: InMemorySessionStore
session_app: SessionMiddleware
def setUp(self) -> None:
self.app = KayaApp()
self.store = InMemorySessionStore()
self.session_app = SessionMiddleware(self.app, self.store)
self.app = KayaApp(mixins=[SessionMixin(self.store)])
@self.session_app.GET('/')
@self.app.GET('/')
async def home(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = n
await ctx.send_str(200, f'visits: {n}')
@self.session_app.GET('/read')
@self.app.GET('/read')
async def read(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0)
await ctx.send_str(200, f'visits: {n}')
@self.session_app.GET('/write')
@self.app.GET('/write')
async def write(ctx: HttpContext) -> None:
ctx.session['foo'] = 'bar'
await ctx.send_str(200, 'ok')
@self.session_app.GET('/clear')
@self.app.GET('/clear')
async def clear(ctx: HttpContext) -> None:
ctx.session.invalidate()
await ctx.send_str(200, 'cleared')
@self.session_app.GET('/rotate')
@self.app.GET('/rotate')
async def rotate(ctx: HttpContext) -> None:
ctx.session.regenerate_id()
await ctx.send_str(200, 'rotated')
@async_test
async def test_session_persists_across_requests(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual(200, r.status_code)
@@ -72,7 +70,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_no_cookie_when_session_not_modified(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/read')
self.assertEqual(200, r.status_code)
@@ -81,7 +79,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_existing_session_refreshes_cookie(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
r = await client.get('/read')
@@ -91,7 +89,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_cookie_attributes(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
set_cookie = r.headers['Set-Cookie']
@@ -102,7 +100,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_sessions_are_isolated(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r1 = await client.get('/')
r2 = await client.get('/')
@@ -115,7 +113,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_invalidate(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
r = await client.get('/clear')
@@ -129,7 +127,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_regenerate_id(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
cookies_before = {c.name: c.value for c in client.cookies.jar}
@@ -153,7 +151,7 @@ class SessionTest(unittest.TestCase):
@async_test
async def test_invalid_cookie_creates_fresh_session(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
client.cookies.set('session_id', 'not-a-real-id')
r = await client.get('/')
@@ -164,19 +162,18 @@ class SessionTest(unittest.TestCase):
async def test_stale_cookie_cannot_access_old_data(self) -> None:
clock = FakeClock()
store = InMemorySessionStore(clock=clock)
app = KayaApp()
session_app = SessionMiddleware(app, store, max_age=60)
app = KayaApp(mixins=[SessionMixin(store, max_age=60)])
@session_app.GET('/')
@app.GET('/')
async def home(ctx: HttpContext) -> None:
ctx.session['secret'] = 'super-sensitive'
await ctx.send_str(200, 'ok')
@session_app.GET('/read')
@app.GET('/read')
async def read(ctx: HttpContext) -> None:
await ctx.send_str(200, ctx.session.get('secret', 'none'))
transport = httpx.ASGITransport(app=session_app)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual('ok', r.text)