Add kaya-session package for server-side HTTP session management
This commit is contained in:
@@ -25,13 +25,17 @@ jobs:
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements-dev.txt
|
||||
- name: Check kaya-core
|
||||
run: |
|
||||
.venv/bin/python -m mypy -p kaya.rsgi
|
||||
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
|
||||
- name: Check kaya-rsgi
|
||||
run: |
|
||||
.venv/bin/python -m mypy -p kaya.core
|
||||
.venv/bin/python -m unittest discover -s packages/kaya-core/tests
|
||||
- name: Check kaya-rsgi
|
||||
run: |
|
||||
.venv/bin/python -m mypy -p kaya.rsgi
|
||||
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
|
||||
- name: Check kaya-session
|
||||
run: |
|
||||
.venv/bin/python -m mypy -p kaya.session
|
||||
.venv/bin/python -m unittest discover -s packages/kaya-session/tests
|
||||
- name: Publish kaya-core artifacts
|
||||
env:
|
||||
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||
@@ -48,3 +52,11 @@ jobs:
|
||||
run: |
|
||||
.venv/bin/pyproject-build packages/kaya-rsgi
|
||||
.venv/bin/twine upload --repository gitea packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz
|
||||
- name: Publish kaya-session artifacts
|
||||
env:
|
||||
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
|
||||
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
|
||||
run: |
|
||||
.venv/bin/pyproject-build packages/kaya-session
|
||||
.venv/bin/twine upload --repository gitea packages/kaya-session/dist/*.whl packages/kaya-session/dist/*.tar.gz
|
||||
|
||||
@@ -8,6 +8,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind
|
||||
|
||||
- **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`)
|
||||
- **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
|
||||
- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`)
|
||||
|
||||
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
||||
|
||||
@@ -22,7 +23,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si
|
||||
Install the packages in development mode:
|
||||
|
||||
```bash
|
||||
pip install -e packages/kaya-core -e packages/kaya-rsgi
|
||||
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session
|
||||
```
|
||||
|
||||
Run the example:
|
||||
@@ -36,6 +37,7 @@ python example/hello.py
|
||||
```bash
|
||||
python -m unittest discover -s packages/kaya-core/tests
|
||||
python -m unittest discover -s packages/kaya-rsgi/tests
|
||||
python -m unittest discover -s packages/kaya-session/tests
|
||||
```
|
||||
|
||||
## Static analysis
|
||||
@@ -43,6 +45,7 @@ python -m unittest discover -s packages/kaya-rsgi/tests
|
||||
```bash
|
||||
mypy -p kaya.core
|
||||
mypy -p kaya.rsgi
|
||||
mypy -p kaya.session
|
||||
```
|
||||
|
||||
## Building packages
|
||||
@@ -50,4 +53,5 @@ mypy -p kaya.rsgi
|
||||
```bash
|
||||
python -m build packages/kaya-core
|
||||
python -m build packages/kaya-rsgi
|
||||
python -m build packages/kaya-session
|
||||
```
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.session import InMemorySessionStore, SessionMiddleware
|
||||
|
||||
app = SessionMiddleware(KayaApp(), InMemorySessionStore())
|
||||
|
||||
|
||||
@app.GET('/')
|
||||
async def home(ctx: HttpContext) -> None:
|
||||
visits = ctx.session.get('visits', 0) + 1
|
||||
ctx.session['visits'] = visits
|
||||
await ctx.send_str(200, f'visits: {visits}')
|
||||
|
||||
|
||||
@app.GET('/read')
|
||||
async def read(ctx: HttpContext) -> None:
|
||||
visits = ctx.session.get('visits', 0)
|
||||
await ctx.send_str(200, f'visits: {visits}')
|
||||
|
||||
|
||||
@app.GET('/clear')
|
||||
async def clear(ctx: HttpContext) -> None:
|
||||
ctx.session.invalidate()
|
||||
await ctx.send_str(200, 'session cleared')
|
||||
|
||||
@app.websocket('/echo')
|
||||
async def echo(ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
async for msg in ws:
|
||||
if msg.kind == 'text':
|
||||
await ws.send_text(f"echo: {msg.data}")
|
||||
elif msg.kind == 'binary':
|
||||
data = msg.data
|
||||
assert isinstance(data, bytes)
|
||||
await ws.send_bytes(data)
|
||||
|
||||
@app.websocket('/ws/visits')
|
||||
async def ws_visits(ws: WebSocket) -> None:
|
||||
# WebSocket handlers can read the existing session. Most ASGI servers
|
||||
# (including Granian and Daphne) do not forward the `headers` field of the
|
||||
# `websocket.accept` message into the HTTP 101 response, so a new session
|
||||
# cookie cannot be set during the handshake. Use the HTTP `/` endpoint to
|
||||
# set or refresh the session cookie before connecting here.
|
||||
await ws.accept()
|
||||
visits = ws.session.get('visits', 0)
|
||||
await ws.send_text(f'visits: {visits}')
|
||||
@@ -88,6 +88,7 @@ class AsgiContext(HttpContext):
|
||||
self.server = scope['server']
|
||||
self.headers = decode_headers(scope['headers'])
|
||||
self.request_body = request_body_iterator
|
||||
self.session = (scope.get('state') or {}).get('kaya_session')
|
||||
|
||||
async def stream_body(self,
|
||||
status: int,
|
||||
|
||||
@@ -28,6 +28,7 @@ class HttpContext(ABC):
|
||||
client: Optional[Tuple[str, int]]
|
||||
server: Optional[Tuple[str, Optional[int]]]
|
||||
request_body: AsyncIterator[bytes]
|
||||
session: Optional[Any] = None
|
||||
|
||||
@abstractmethod
|
||||
async def stream_body(self,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# kaya-session
|
||||
|
||||
Session management for the Kaya web framework.
|
||||
|
||||
Provides server-side, identity-agnostic HTTP sessions via a session cookie. The
|
||||
session data is accessible from request handlers as `ctx.session`.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from kaya.core import KayaApp, HttpContext
|
||||
from kaya.session import SessionMiddleware, InMemorySessionStore
|
||||
|
||||
app = KayaApp()
|
||||
session_app = SessionMiddleware(app, InMemorySessionStore())
|
||||
|
||||
@session_app.GET('/')
|
||||
async def home(ctx: HttpContext):
|
||||
n = ctx.session.get('visits', 0) + 1
|
||||
ctx.session['visits'] = n
|
||||
await ctx.send_str(200, f'visits: {n}')
|
||||
```
|
||||
|
||||
Sessions are created lazily: a cookie is only set when the handler modifies the
|
||||
session.
|
||||
|
||||
## Features
|
||||
|
||||
- `Session`: dict-like session object with modification tracking
|
||||
- `SessionStore`: abstract store interface
|
||||
- `InMemorySessionStore`: simple in-memory store for development/single-process
|
||||
- `SessionMiddleware`: ASGI middleware managing session cookies and persistence
|
||||
- Session ID regeneration (`session.regenerate_id()`) and invalidation
|
||||
(`session.invalidate()`) for future authentication layers
|
||||
|
||||
## Notes
|
||||
|
||||
- This release supports HTTP requests only; WebSocket and RSGI propagation is
|
||||
planned for future releases.
|
||||
- `InMemorySessionStore` does not survive process restarts and is not shared
|
||||
across processes. Production deployments should use a store backed by a shared
|
||||
storage system (planned).
|
||||
@@ -0,0 +1,57 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kaya-session"
|
||||
dynamic = ["version"]
|
||||
authors = [
|
||||
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
|
||||
]
|
||||
description = "Session management for the Kaya lightweight ASGI web framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "MIT"
|
||||
classifiers = [
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Topic :: Utilities',
|
||||
'Intended Audience :: System Administrators',
|
||||
'Intended Audience :: Developers',
|
||||
'Environment :: Console',
|
||||
'Programming Language :: Python :: 3',
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"pwo",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/woggioni/kaya"
|
||||
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
disallow_untyped_defs = true
|
||||
show_error_codes = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
exclude = ["scripts", "docs", "test"]
|
||||
strict = true
|
||||
|
||||
[tool.setuptools_scm]
|
||||
root = "../.."
|
||||
version_file = "src/kaya/session/_version.py"
|
||||
|
||||
[tool.setuptools_scm.tag]
|
||||
prefix = "release/"
|
||||
@@ -0,0 +1,11 @@
|
||||
from ._middleware import SessionMiddleware
|
||||
from ._session import Session
|
||||
from ._store import InMemorySessionStore, SessionStore
|
||||
|
||||
|
||||
__all__ = [
|
||||
'InMemorySessionStore',
|
||||
'Session',
|
||||
'SessionMiddleware',
|
||||
'SessionStore',
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_cookie_value(header_value: str, cookie_name: str) -> Optional[str]:
|
||||
"""Return the value of ``cookie_name`` from a ``Cookie`` header, if present."""
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(header_value)
|
||||
morsel = cookie.get(cookie_name)
|
||||
if morsel is None:
|
||||
return None
|
||||
return morsel.value
|
||||
|
||||
|
||||
def format_set_cookie(
|
||||
name: str,
|
||||
value: str,
|
||||
path: str = '/',
|
||||
max_age: Optional[int] = None,
|
||||
httponly: bool = True,
|
||||
secure: bool = False,
|
||||
samesite: Optional[str] = 'Lax',
|
||||
) -> str:
|
||||
"""Return a ``Set-Cookie`` value string (without the header name)."""
|
||||
cookie = SimpleCookie()
|
||||
cookie[name] = value
|
||||
morsel = cookie[name]
|
||||
morsel['path'] = path
|
||||
if max_age is not None:
|
||||
morsel['max-age'] = max_age
|
||||
if httponly:
|
||||
morsel['httponly'] = True
|
||||
if secure:
|
||||
morsel['secure'] = True
|
||||
if samesite is not None:
|
||||
morsel['samesite'] = samesite
|
||||
return morsel.OutputString()
|
||||
@@ -0,0 +1,178 @@
|
||||
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Sequence, cast
|
||||
|
||||
from kaya.core import HttpMethod, KayaApp
|
||||
|
||||
from ._cookie import format_set_cookie, parse_cookie_value
|
||||
from ._session import Session
|
||||
from ._store import SessionStore
|
||||
|
||||
|
||||
type HttpHandler = Callable[[Any, Any], Awaitable[None]]
|
||||
type WebSocketHandler = Callable[[Any, Any], Awaitable[None]]
|
||||
type RouteDecorator = Callable[[HttpHandler], HttpHandler]
|
||||
type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler]
|
||||
type ASGIApp = Callable[
|
||||
[MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]],
|
||||
Awaitable[None],
|
||||
]
|
||||
|
||||
|
||||
class SessionMiddleware:
|
||||
"""ASGI middleware providing server-side HTTP sessions.
|
||||
|
||||
The middleware reads a session cookie from each HTTP request, loads the
|
||||
session data via the configured store, and makes the session available to
|
||||
Kaya handlers as ``ctx.session``. After the request it persists the session
|
||||
and refreshes the cookie.
|
||||
|
||||
Routing methods are delegated to the wrapped ``KayaApp`` so the middleware
|
||||
can be used as a drop-in replacement when registering handlers.
|
||||
|
||||
Example::
|
||||
|
||||
app = KayaApp()
|
||||
session_app = SessionMiddleware(app, InMemorySessionStore())
|
||||
|
||||
@session_app.GET('/')
|
||||
async def home(ctx: HttpContext):
|
||||
ctx.session['visits'] = ctx.session.get('visits', 0) + 1
|
||||
await ctx.send_str(200, f"visits: {ctx.session['visits']}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: KayaApp,
|
||||
store: SessionStore,
|
||||
cookie_name: str = 'session_id',
|
||||
path: str = '/',
|
||||
max_age: Optional[int] = 14 * 24 * 60 * 60,
|
||||
httponly: bool = True,
|
||||
secure: bool = False,
|
||||
samesite: Optional[str] = 'Lax',
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._store = store
|
||||
self._cookie_name = cookie_name
|
||||
self._path = path
|
||||
self._max_age = max_age
|
||||
self._httponly = httponly
|
||||
self._secure = secure
|
||||
self._samesite = samesite
|
||||
|
||||
def route(
|
||||
self,
|
||||
paths: str | Sequence[str],
|
||||
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
|
||||
recursive: bool = False,
|
||||
) -> RouteDecorator:
|
||||
return self._app.route(paths, methods, recursive)
|
||||
|
||||
def GET(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.GET(path, recursive)
|
||||
|
||||
def POST(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.POST(path, recursive)
|
||||
|
||||
def PUT(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.PUT(path, recursive)
|
||||
|
||||
def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.DELETE(path, recursive)
|
||||
|
||||
def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.OPTIONS(path, recursive)
|
||||
|
||||
def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.HEAD(path, recursive)
|
||||
|
||||
def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator:
|
||||
return self._app.PATCH(path, recursive)
|
||||
|
||||
def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator:
|
||||
return self._app.websocket(path, recursive)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
scope: MutableMapping[str, Any],
|
||||
receive: Callable[[], Awaitable[Any]],
|
||||
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
if scope['type'] != 'http':
|
||||
await cast(ASGIApp, self._app)(scope, receive, send)
|
||||
return
|
||||
|
||||
session_id = self._extract_session_id(scope)
|
||||
session: Session
|
||||
if session_id is not None:
|
||||
loaded = await self._store.load(session_id)
|
||||
session = loaded if loaded is not None else Session()
|
||||
else:
|
||||
session = Session()
|
||||
|
||||
state = scope.get('state')
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
scope['state'] = state
|
||||
state['kaya_session'] = session
|
||||
|
||||
async def wrapped_send(message: MutableMapping[str, Any]) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
message = dict(message)
|
||||
final_session_id = self._finalize_session_id(session)
|
||||
if final_session_id is not None:
|
||||
cookie_value = format_set_cookie(
|
||||
self._cookie_name,
|
||||
final_session_id,
|
||||
path=self._path,
|
||||
max_age=0 if session.invalidated else self._max_age,
|
||||
httponly=self._httponly,
|
||||
secure=self._secure,
|
||||
samesite=self._samesite,
|
||||
)
|
||||
headers = list(message.get('headers', []))
|
||||
headers.append((b'Set-Cookie', cookie_value.encode()))
|
||||
message['headers'] = headers
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await cast(ASGIApp, self._app)(scope, receive, wrapped_send)
|
||||
finally:
|
||||
await self._persist(session)
|
||||
|
||||
def _extract_session_id(self, scope: MutableMapping[str, Any]) -> Optional[str]:
|
||||
headers = scope.get('headers', [])
|
||||
for key, value in headers:
|
||||
key_bytes: bytes = key if isinstance(key, bytes) else key.encode()
|
||||
if key_bytes.lower() == b'cookie':
|
||||
value_str: str = value.decode() if isinstance(value, bytes) else value
|
||||
return parse_cookie_value(value_str, self._cookie_name)
|
||||
return None
|
||||
|
||||
def _finalize_session_id(self, session: Session) -> Optional[str]:
|
||||
if session.invalidated:
|
||||
return session.id
|
||||
if session.id is None:
|
||||
if session.modified or session.regenerate:
|
||||
session.set_id(self._store.new_session_id())
|
||||
elif session.regenerate:
|
||||
session._old_id = session.id
|
||||
session.set_id(self._store.new_session_id())
|
||||
session._regenerate = False
|
||||
return session.id
|
||||
|
||||
async def _persist(self, session: Session) -> None:
|
||||
if session.invalidated:
|
||||
old_id = session._old_id or session.id
|
||||
if old_id is not None:
|
||||
await self._store.delete(old_id)
|
||||
return
|
||||
|
||||
if session._old_id is not None and session._old_id != session.id:
|
||||
await self._store.delete(session._old_id)
|
||||
session._old_id = None
|
||||
|
||||
if session.id is None and session.modified:
|
||||
session.set_id(self._store.new_session_id())
|
||||
|
||||
if session.id is not None:
|
||||
await self._store.save(session.id, session)
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Any, Iterator, Mapping, MutableMapping, Optional
|
||||
|
||||
|
||||
class Session(MutableMapping[str, Any]):
|
||||
"""Dict-like session container with modification tracking.
|
||||
|
||||
The middleware uses the ``modified``, ``regenerate`` and ``invalidated``
|
||||
flags to decide whether to persist the session, rotate its ID, or delete
|
||||
it.
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: Optional[str] = None, data: Optional[Mapping[str, Any]] = None) -> None:
|
||||
self._id: Optional[str] = session_id
|
||||
self._old_id: Optional[str] = None
|
||||
self._data: dict[str, Any] = dict(data) if data else {}
|
||||
self._modified: bool = False
|
||||
self._regenerate: bool = False
|
||||
self._invalidated: bool = False
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[str]:
|
||||
return self._id
|
||||
|
||||
def set_id(self, session_id: str) -> None:
|
||||
self._id = session_id
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
return self._modified
|
||||
|
||||
@property
|
||||
def regenerate(self) -> bool:
|
||||
return self._regenerate
|
||||
|
||||
@property
|
||||
def invalidated(self) -> bool:
|
||||
return self._invalidated
|
||||
|
||||
def regenerate_id(self) -> None:
|
||||
"""Mark the session for ID rotation.
|
||||
|
||||
This is intended for authentication layers to defend against session
|
||||
fixation: the middleware will create a new session ID, move the data to
|
||||
it, and delete the old store entry.
|
||||
"""
|
||||
self._old_id = self._id
|
||||
self._id = None
|
||||
self._regenerate = True
|
||||
self._modified = True
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Mark the session for deletion.
|
||||
|
||||
The middleware will clear the stored data and send an expired cookie.
|
||||
"""
|
||||
self._invalidated = True
|
||||
self._modified = True
|
||||
self._data.clear()
|
||||
|
||||
def mark_modified(self) -> None:
|
||||
self._modified = True
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._data[key]
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self._data[key] = value
|
||||
self._modified = True
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self._data[key]
|
||||
self._modified = True
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._data)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._data.clear()
|
||||
self._modified = True
|
||||
@@ -0,0 +1,54 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Optional
|
||||
|
||||
from ._session import Session
|
||||
|
||||
|
||||
class SessionStore(ABC):
|
||||
"""Pluggable backend for session persistence."""
|
||||
|
||||
@abstractmethod
|
||||
async def load(self, session_id: str) -> Optional[Session]:
|
||||
"""Load an existing session, or return ``None`` if unknown/expired."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def save(self, session_id: str, session: Session) -> None:
|
||||
"""Persist the session data under ``session_id``."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, session_id: str) -> None:
|
||||
"""Remove the session from the store."""
|
||||
pass
|
||||
|
||||
def new_session_id(self) -> str:
|
||||
"""Return a new opaque session identifier.
|
||||
|
||||
Subclasses may override this to use a backend-specific ID generator.
|
||||
"""
|
||||
return token_urlsafe(32)
|
||||
|
||||
|
||||
class InMemorySessionStore(SessionStore):
|
||||
"""Simple in-memory session store.
|
||||
|
||||
Suitable for development and single-process deployments. Session data is
|
||||
lost when the process exits and is not shared between processes.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def load(self, session_id: str) -> Optional[Session]:
|
||||
data = self._sessions.get(session_id)
|
||||
if data is None:
|
||||
return None
|
||||
return Session(session_id, data)
|
||||
|
||||
async def save(self, session_id: str, session: Session) -> None:
|
||||
self._sessions[session_id] = dict(session)
|
||||
|
||||
async def delete(self, session_id: str) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
@@ -0,0 +1,245 @@
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pwo import async_test
|
||||
from kaya.core import KayaApp, HttpContext
|
||||
|
||||
from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore
|
||||
from kaya.session._cookie import format_set_cookie, parse_cookie_value
|
||||
|
||||
|
||||
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.session_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')
|
||||
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')
|
||||
async def write(ctx: HttpContext) -> None:
|
||||
ctx.session['foo'] = 'bar'
|
||||
await ctx.send_str(200, 'ok')
|
||||
|
||||
@self.session_app.GET('/clear')
|
||||
async def clear(ctx: HttpContext) -> None:
|
||||
ctx.session.invalidate()
|
||||
await ctx.send_str(200, 'cleared')
|
||||
|
||||
@self.session_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)
|
||||
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)
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
self.assertIn('Set-Cookie', r.headers)
|
||||
|
||||
r = await client.get('/')
|
||||
self.assertEqual(200, r.status_code)
|
||||
self.assertEqual('visits: 2', r.text)
|
||||
|
||||
@async_test
|
||||
async def test_no_cookie_when_session_not_modified(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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)
|
||||
self.assertEqual('visits: 0', r.text)
|
||||
self.assertNotIn('Set-Cookie', r.headers)
|
||||
|
||||
@async_test
|
||||
async def test_existing_session_refreshes_cookie(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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')
|
||||
self.assertEqual(200, r.status_code)
|
||||
self.assertIn('Set-Cookie', r.headers)
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
|
||||
@async_test
|
||||
async def test_cookie_attributes(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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']
|
||||
self.assertIn('HttpOnly', set_cookie)
|
||||
self.assertIn('SameSite=Lax', set_cookie)
|
||||
self.assertIn('Path=/', set_cookie)
|
||||
self.assertIn('Max-Age=', set_cookie)
|
||||
|
||||
@async_test
|
||||
async def test_sessions_are_isolated(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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('/')
|
||||
self.assertEqual('visits: 1', r1.text)
|
||||
self.assertEqual('visits: 2', r2.text)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
||||
r = await client.get('/')
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
|
||||
@async_test
|
||||
async def test_invalidate(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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')
|
||||
self.assertEqual(200, r.status_code)
|
||||
self.assertEqual('cleared', r.text)
|
||||
set_cookie = r.headers['Set-Cookie']
|
||||
self.assertIn('Max-Age=0', set_cookie)
|
||||
|
||||
r = await client.get('/')
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
|
||||
@async_test
|
||||
async def test_regenerate_id(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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}
|
||||
old_id = cookies_before.get('session_id')
|
||||
self.assertIsNotNone(old_id)
|
||||
self.assertIn(old_id, self.store._sessions)
|
||||
|
||||
r = await client.get('/rotate')
|
||||
self.assertEqual(200, r.status_code)
|
||||
self.assertEqual('rotated', r.text)
|
||||
|
||||
cookies_after = {c.name: c.value for c in client.cookies.jar}
|
||||
new_id = cookies_after.get('session_id')
|
||||
self.assertIsNotNone(new_id)
|
||||
self.assertNotEqual(old_id, new_id)
|
||||
self.assertNotIn(old_id, self.store._sessions)
|
||||
self.assertIn(new_id, self.store._sessions)
|
||||
|
||||
r = await client.get('/read')
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
|
||||
@async_test
|
||||
async def test_invalid_cookie_creates_fresh_session(self) -> None:
|
||||
transport = httpx.ASGITransport(app=self.session_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('/')
|
||||
self.assertEqual('visits: 1', r.text)
|
||||
self.assertIn('Set-Cookie', r.headers)
|
||||
|
||||
|
||||
class SessionUnitTest(unittest.TestCase):
|
||||
|
||||
def test_session_is_dict_like(self) -> None:
|
||||
session = Session()
|
||||
session['a'] = 1
|
||||
self.assertEqual(1, session['a'])
|
||||
self.assertTrue('a' in session)
|
||||
self.assertEqual({'a': 1}, dict(session))
|
||||
self.assertTrue(session.modified)
|
||||
|
||||
def test_session_modification_tracking(self) -> None:
|
||||
session = Session(data={'a': 1})
|
||||
self.assertFalse(session.modified)
|
||||
session['a'] = 2
|
||||
self.assertTrue(session.modified)
|
||||
|
||||
def test_session_read_does_not_mark_modified(self) -> None:
|
||||
session = Session(data={'a': 1})
|
||||
self.assertFalse(session.modified)
|
||||
_ = session['a']
|
||||
self.assertFalse(session.modified)
|
||||
|
||||
def test_session_clear_marks_modified(self) -> None:
|
||||
session = Session(data={'a': 1})
|
||||
self.assertFalse(session.modified)
|
||||
session.clear()
|
||||
self.assertTrue(session.modified)
|
||||
self.assertEqual(0, len(session))
|
||||
|
||||
def test_session_invalidate(self) -> None:
|
||||
session = Session(session_id='abc', data={'a': 1})
|
||||
self.assertFalse(session.modified)
|
||||
session.invalidate()
|
||||
self.assertTrue(session.invalidated)
|
||||
self.assertTrue(session.modified)
|
||||
self.assertEqual(0, len(session))
|
||||
|
||||
def test_session_regenerate_id(self) -> None:
|
||||
session = Session(session_id='abc', data={'a': 1})
|
||||
session.regenerate_id()
|
||||
self.assertTrue(session.regenerate)
|
||||
self.assertIsNone(session.id)
|
||||
self.assertEqual('abc', session._old_id)
|
||||
self.assertTrue(session.modified)
|
||||
|
||||
def test_session_store_is_abstract(self) -> None:
|
||||
with self.assertRaises(TypeError):
|
||||
SessionStore() # type: ignore[abstract]
|
||||
|
||||
def test_in_memory_store_round_trip(self) -> None:
|
||||
store = InMemorySessionStore()
|
||||
session_id = store.new_session_id()
|
||||
session = Session(session_id, {'a': 1})
|
||||
self.assertIsNone(store._sessions.get(session_id))
|
||||
|
||||
import asyncio
|
||||
asyncio.run(store.save(session_id, session))
|
||||
|
||||
loaded = asyncio.run(store.load(session_id))
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual(1, loaded['a'])
|
||||
|
||||
asyncio.run(store.delete(session_id))
|
||||
self.assertIsNone(asyncio.run(store.load(session_id)))
|
||||
|
||||
|
||||
class CookieUtilTest(unittest.TestCase):
|
||||
|
||||
def test_parse_cookie_value(self) -> None:
|
||||
self.assertEqual('bar', parse_cookie_value('foo=bar; baz=qux', 'foo'))
|
||||
self.assertEqual('qux', parse_cookie_value('foo=bar; baz=qux', 'baz'))
|
||||
self.assertIsNone(parse_cookie_value('foo=bar', 'missing'))
|
||||
self.assertIsNone(parse_cookie_value('', 'foo'))
|
||||
|
||||
def test_format_set_cookie(self) -> None:
|
||||
value = format_set_cookie('sid', 'abc123', path='/', max_age=3600, httponly=True, secure=True, samesite='Strict')
|
||||
self.assertIn('sid=abc123', value)
|
||||
self.assertIn('Path=/', value)
|
||||
self.assertIn('Max-Age=3600', value)
|
||||
self.assertIn('HttpOnly', value)
|
||||
self.assertIn('Secure', value)
|
||||
self.assertIn('SameSite=Strict', value)
|
||||
|
||||
def test_format_set_cookie_without_secure(self) -> None:
|
||||
value = format_set_cookie('sid', 'abc123', max_age=3600)
|
||||
self.assertIn('sid=abc123', value)
|
||||
self.assertIn('HttpOnly', value)
|
||||
self.assertNotIn('Secure', value)
|
||||
self.assertIn('SameSite=Lax', value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
kaya-core @ file:./packages/kaya-core
|
||||
kaya-rsgi @ file:./packages/kaya-rsgi
|
||||
kaya-session @ file:./packages/kaya-session
|
||||
build
|
||||
mypy
|
||||
ipdb
|
||||
|
||||
@@ -83,8 +83,11 @@ file:./packages/kaya-core
|
||||
# via
|
||||
# -r requirements-dev.in
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
file:./packages/kaya-rsgi
|
||||
# via -r requirements-dev.in
|
||||
file:./packages/kaya-session
|
||||
# via -r requirements-dev.in
|
||||
keyring==25.7.0
|
||||
# via twine
|
||||
librt==0.13.0
|
||||
|
||||
Reference in New Issue
Block a user