A channel's message slots are named from the client, on every backend
The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,98 +1,31 @@
|
||||
"""
|
||||
mizan - Django + React unified framework
|
||||
The mizan package surface: the `client` decorator with its context types, the
|
||||
`Channel` base and its registry, the form/shape/export submodules, and
|
||||
`wrap_asgi`, which mounts the WebSocket consumer alongside an HTTP application.
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. urls.py - HTTP endpoint
|
||||
```python
|
||||
from mizan import urls as mizan_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('api/mizan/', include(mizan_urls)),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. asgi.py - WebSocket support (optional)
|
||||
```python
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
### 3. Define server functions
|
||||
```python
|
||||
# apps/myapp/clients.py
|
||||
from mizan import client
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
# HTTP-only function (default)
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=f"Echo: {text}")
|
||||
|
||||
# Global context (singleton, SSR-hydrated)
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
|
||||
# WebSocket-enabled for real-time
|
||||
@client(websocket=True)
|
||||
def send_message(request, room_id: int, text: str) -> MessageOutput:
|
||||
return MessageOutput(...)
|
||||
```
|
||||
|
||||
### 4. Auto-discover in apps.py
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
from mizan.setup import mizan_clients
|
||||
mizan_clients('apps')
|
||||
```
|
||||
|
||||
### 5. Frontend - generate types and use
|
||||
```bash
|
||||
npm run schemas
|
||||
```
|
||||
```tsx
|
||||
import { useEcho, useCurrentUser } from '@/api'
|
||||
|
||||
const user = useCurrentUser()
|
||||
const echo = useEcho()
|
||||
await echo({ text: 'hello' })
|
||||
```
|
||||
|
||||
## What You Get
|
||||
|
||||
| Backend | Frontend | Transport |
|
||||
|------------------------------------|-----------------------|------------|
|
||||
| `@client` | `useXxx()` hook | HTTP |
|
||||
| `@client(context='global')` | `useXxx()` + SSR | HTTP |
|
||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
`urls` and `Shape` resolve through `__getattr__` rather than at import time.
|
||||
"""
|
||||
|
||||
# All imports at module level (sorted)
|
||||
from . import channels
|
||||
from . import client as client_module
|
||||
from . import export
|
||||
from . import forms
|
||||
from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||
from mizan import channels
|
||||
from mizan import client as client_module
|
||||
from mizan import export
|
||||
from mizan import forms
|
||||
from mizan import setup
|
||||
from mizan.channels import Channel
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.client import (
|
||||
ComposedContext,
|
||||
GlobalContext,
|
||||
ReactContext,
|
||||
ServerFunction,
|
||||
client,
|
||||
compose,
|
||||
)
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
from .setup import (
|
||||
from mizan.setup import (
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
get_channel,
|
||||
@@ -105,11 +38,11 @@ from .setup import (
|
||||
def __getattr__(name):
|
||||
"""Lazy loading for modules that can't be imported at app load time."""
|
||||
if name == "urls":
|
||||
from .urls import urlpatterns as mizan_patterns
|
||||
from mizan.urls import urlpatterns as mizan_patterns
|
||||
|
||||
return mizan_patterns
|
||||
if name == "Shape":
|
||||
from .shapes import Shape
|
||||
from mizan.shapes import Shape
|
||||
|
||||
return Shape
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -117,17 +50,8 @@ def __getattr__(name):
|
||||
|
||||
def wrap_asgi(http_application):
|
||||
"""
|
||||
Wrap an ASGI application with mizan WebSocket support.
|
||||
|
||||
Usage in asgi.py:
|
||||
from django.core.asgi import get_asgi_application
|
||||
from mizan import wrap_asgi
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
This adds:
|
||||
- WebSocket routing at /ws/ for RPC and channels
|
||||
- Authentication middleware for WebSocket connections
|
||||
Route HTTP to `http_application` and /ws/ to the mizan consumer, with the
|
||||
channels auth middleware supplying `scope["user"]` on the socket branch.
|
||||
"""
|
||||
try:
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
@@ -140,7 +64,7 @@ def wrap_asgi(http_application):
|
||||
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
|
||||
)
|
||||
|
||||
from .channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
return ProtocolTypeRouter(
|
||||
{
|
||||
@@ -174,7 +98,7 @@ __all__ = [
|
||||
# ASGI
|
||||
"wrap_asgi",
|
||||
# Channels
|
||||
"ReactChannel",
|
||||
"Channel",
|
||||
"register_channel",
|
||||
# Shapes
|
||||
"Shape",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import inspect
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from importlib import import_module
|
||||
from inspect import isclass
|
||||
from typing import Protocol, Any
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def get_members(path):
|
||||
try:
|
||||
module = import_module(path)
|
||||
except ModuleNotFoundError:
|
||||
print('Could not import module "{}"'.format(path))
|
||||
except ModuleNotFoundError as exc:
|
||||
# Callers of this module write machine-read output to stdout, so the
|
||||
# diagnostic must not share that stream.
|
||||
print(f'Could not import module "{path}": {exc}', file=sys.stderr)
|
||||
return []
|
||||
|
||||
members = [
|
||||
@@ -21,7 +26,8 @@ def get_members(path):
|
||||
return members
|
||||
|
||||
|
||||
class DjangoAppVisitorHandler(Protocol):
|
||||
class DjangoAppVisitorHandler(ABC):
|
||||
@abstractmethod
|
||||
def on_module(
|
||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||
) -> None: ...
|
||||
@@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol):
|
||||
|
||||
class DjangoAppVisitor:
|
||||
"""
|
||||
Discovers Python modules under each Django app following conventions:
|
||||
- <app>/<module>.py -> url_prefix "<renamed>/"
|
||||
- <app>/<module>/**/*.py -> url_prefix "<renamed>/<subdirs...>/<module>/"
|
||||
Walks each installed app for modules named after `layer`:
|
||||
<app>/<layer>.py -> path_parts []
|
||||
<app>/<layer>/**/*.py -> path_parts [<subdirs...>, <stem>]
|
||||
|
||||
Example:
|
||||
<app>/<module>/forms/nksn.py -> url_prefix "<renamed>/forms/nksn/"
|
||||
module_path "<app>.module.forms.nksn"
|
||||
`apps_root` is the dotted package the apps live under, relative to
|
||||
BASE_DIR; "" means the apps sit directly at BASE_DIR.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -66,7 +71,6 @@ class DjangoAppVisitor:
|
||||
|
||||
app_module = f"{module_prefix}{app_name}"
|
||||
|
||||
# 1) Visit package: <app>/<module>/**/*.py
|
||||
layer_dir = app_dir / self.layer
|
||||
if layer_dir.is_dir():
|
||||
for py_file in layer_dir.rglob("*.py"):
|
||||
@@ -83,7 +87,6 @@ class DjangoAppVisitor:
|
||||
get_members(f"{app_module}.{self.layer}.{dotted}"),
|
||||
)
|
||||
|
||||
# 2) Visit module module file: <app>/module.py
|
||||
layer_file = app_dir / f"{self.layer}.py"
|
||||
if layer_file.is_file():
|
||||
handler.on_module(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""
|
||||
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
|
||||
Origin-side cache keyed by HMAC digests of (context, params, user, rev).
|
||||
|
||||
Simple key-value cache with HMAC-derived keys. No reverse indexes.
|
||||
Scoped purge recomputes the key and deletes directly.
|
||||
Broad purge uses key-prefix scan (rare operation).
|
||||
|
||||
Usage:
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
There are no reverse indexes: a scoped purge recomputes the one key it needs
|
||||
and deletes it, and a purge with no params falls back to a key-prefix scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,7 +24,7 @@ _init_lock = threading.Lock()
|
||||
def get_cache() -> CacheBackend | None:
|
||||
"""
|
||||
Get the configured cache backend, or None if caching is disabled.
|
||||
Thread-safe.
|
||||
Thread-safe; the backend is built once on first call.
|
||||
"""
|
||||
global _cache_instance, _initialized
|
||||
if _initialized:
|
||||
@@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None:
|
||||
from mizan.setup.settings import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
# Both settings are required; one without the other is a
|
||||
# misconfiguration worth naming rather than silently ignoring.
|
||||
if settings.cache_secret and settings.cache_redis_url:
|
||||
_cache_instance = RedisCache(settings.cache_redis_url)
|
||||
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
|
||||
@@ -113,13 +111,11 @@ def cache_purge(
|
||||
rev: int = 0,
|
||||
) -> int:
|
||||
"""
|
||||
Purge cached entries for a context.
|
||||
Purge cached entries for a context and return how many were removed.
|
||||
|
||||
Scoped purge (params provided): recomputes the HMAC key and deletes
|
||||
it directly. One DELETE, no index needed.
|
||||
|
||||
Broad purge (no params): scans by key prefix "ctx:{context}:*".
|
||||
This is a rare operation (Tier 3 fallback in invalidation).
|
||||
With params and a secret, the exact key is recomputed and deleted — one
|
||||
DELETE. Without them, every key under the prefix "ctx:{context}:" is
|
||||
scanned and removed.
|
||||
"""
|
||||
if params is not None and len(params) > 0 and secret:
|
||||
key = derive_cache_key(secret, context, params, user_id, rev)
|
||||
|
||||
@@ -1,81 +1,15 @@
|
||||
"""
|
||||
mizan.channels - Real-time WebSocket communication.
|
||||
|
||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||
Hooks are auto-generated with full TypeScript types.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```python
|
||||
# channels.py
|
||||
from pydantic import BaseModel
|
||||
from mizan import channels
|
||||
|
||||
class ChatChannel(channels.ReactChannel):
|
||||
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
timestamp: datetime
|
||||
|
||||
def authorize(self, params: Params) -> bool:
|
||||
return self.user.is_authenticated
|
||||
|
||||
def group(self, params: Params) -> str:
|
||||
return f'chat_{params.room}'
|
||||
|
||||
def receive(self, params: Params, msg: ReactMessage) -> DjangoMessage | None:
|
||||
return self.DjangoMessage(
|
||||
user=self.user.email,
|
||||
text=msg.text,
|
||||
timestamp=now(),
|
||||
)
|
||||
|
||||
channels.register(ChatChannel, 'chat')
|
||||
```
|
||||
|
||||
```python
|
||||
# asgi.py
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": channels.get_websocket_application(),
|
||||
})
|
||||
```
|
||||
|
||||
## Frontend Usage (auto-generated)
|
||||
|
||||
```tsx
|
||||
import { useChatChannel } from '@/api/generated.channels'
|
||||
|
||||
function Chat({ room }) {
|
||||
const chat = useChatChannel({ room })
|
||||
|
||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
chat.messages // DjangoMessage[]
|
||||
chat.send({ text: 'Hello' }) // ReactMessage
|
||||
}
|
||||
```
|
||||
|
||||
## Server Push
|
||||
|
||||
```python
|
||||
await ChatChannel.push(room='general', message=ChatChannel.DjangoMessage(...))
|
||||
```
|
||||
"""
|
||||
"""WebSocket channels: the Channel base class, the channel registry, and
|
||||
the schema exports built from it."""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.ir import wire_to_pascal
|
||||
from mizan_core.registry import RegistryExtension, register_extension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
||||
from ninja import NinjaAPI
|
||||
@@ -84,36 +18,25 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
class Channel(abc.ABC):
|
||||
"""
|
||||
Base class for WebSocket channels.
|
||||
A WebSocket channel.
|
||||
|
||||
Define nested Pydantic classes for typed messaging:
|
||||
- Params: Query parameters for subscribing (optional)
|
||||
- ReactMessage: Messages from browser to server (optional)
|
||||
- DjangoMessage: Messages from server to browser (optional)
|
||||
Subclasses declare the wire types as nested Pydantic models:
|
||||
Params (subscription query parameters), ClientMessage (travels
|
||||
client -> server), ServerMessage (travels server -> client). Any
|
||||
slot left undeclared stays None and that direction is unavailable.
|
||||
|
||||
Implement required methods:
|
||||
- authorize(): Permission check for connection
|
||||
- group(): Which group to broadcast to
|
||||
|
||||
Optionally implement:
|
||||
- receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast
|
||||
- on_connect(): Called after successful connection
|
||||
- on_disconnect(): Called when connection closes
|
||||
authorize() and group() are abstract. receive(), on_connect() and
|
||||
on_disconnect() are the override points; each definition here records
|
||||
what happened and a subclass replaces or extends it.
|
||||
"""
|
||||
|
||||
# Nested classes (optional, defined by subclasses)
|
||||
Params: ClassVar[Type[BaseModel] | None] = None
|
||||
ReactMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
DjangoMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
ClientMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
ServerMessage: ClassVar[Type[BaseModel] | None] = None
|
||||
|
||||
# Set by the framework when handling a connection
|
||||
# Set by the consumer when it builds an instance for a subscription.
|
||||
user: "AbstractBaseUser | AnonymousUser"
|
||||
_channel_layer: Any = None
|
||||
_channel_name: str = ""
|
||||
@@ -125,64 +48,58 @@ class ReactChannel:
|
||||
self._groups = set()
|
||||
self._params_dict = {}
|
||||
|
||||
@abc.abstractmethod
|
||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||
"""
|
||||
Permission check. Return True to allow connection, False to reject.
|
||||
|
||||
Override this to implement custom authorization logic.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} must implement authorize()"
|
||||
)
|
||||
"""Return True to allow the connection, False to reject it."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def group(self, params: BaseModel | None = None) -> str:
|
||||
"""
|
||||
Return the group name for broadcasting.
|
||||
|
||||
Messages returned from receive() are broadcast to this group.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||
"""Return the channel-layer group name this subscription broadcasts to."""
|
||||
|
||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||
"""
|
||||
Handle incoming ReactMessage.
|
||||
|
||||
Return a DjangoMessage to broadcast to the group, or None to skip.
|
||||
Override this to implement message handling.
|
||||
Handle one ClientMessage; a returned ServerMessage is broadcast to the
|
||||
group. A channel that accepts inbound frames overrides this — reaching
|
||||
the definition here means the frame has nowhere to go.
|
||||
"""
|
||||
logger.warning(
|
||||
"%s does not handle inbound %s; the frame is dropped",
|
||||
type(self).__name__,
|
||||
type(msg).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
async def on_connect(self, params: BaseModel | None = None) -> None:
|
||||
"""Called after successful connection and group join."""
|
||||
pass
|
||||
"""Runs after the group join; a subclass extends it via super()."""
|
||||
logger.debug(
|
||||
"%s subscription opened on %s",
|
||||
type(self).__name__,
|
||||
self._channel_name or "<no channel name>",
|
||||
)
|
||||
|
||||
async def on_disconnect(self) -> None:
|
||||
"""Called when the connection closes."""
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal Methods (used by the consumer)
|
||||
# -------------------------------------------------------------------------
|
||||
"""Runs as the subscription closes; a subclass extends it via super()."""
|
||||
logger.debug(
|
||||
"%s subscription closed, leaving %d group(s)",
|
||||
type(self).__name__,
|
||||
len(self._groups),
|
||||
)
|
||||
|
||||
async def _join_group(self, group_name: str) -> None:
|
||||
"""Join a channel layer group."""
|
||||
if self._channel_layer:
|
||||
await self._channel_layer.group_add(group_name, self._channel_name)
|
||||
self._groups.add(group_name)
|
||||
|
||||
async def _leave_group(self, group_name: str) -> None:
|
||||
"""Leave a channel layer group."""
|
||||
if self._channel_layer and group_name in self._groups:
|
||||
await self._channel_layer.group_discard(group_name, self._channel_name)
|
||||
self._groups.discard(group_name)
|
||||
|
||||
async def _leave_all_groups(self) -> None:
|
||||
"""Leave all joined groups."""
|
||||
for group_name in list(self._groups):
|
||||
await self._leave_group(group_name)
|
||||
|
||||
async def _broadcast(self, group_name: str, message: BaseModel) -> None:
|
||||
"""Broadcast a message to a group."""
|
||||
if self._channel_layer:
|
||||
await self._channel_layer.group_send(
|
||||
group_name,
|
||||
@@ -195,20 +112,11 @@ class ReactChannel:
|
||||
},
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Class Methods for Server Push
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def push(cls, message: BaseModel, **params) -> None:
|
||||
"""
|
||||
Push a message from server code (views, tasks, signals).
|
||||
|
||||
Usage:
|
||||
await ChatChannel.push(
|
||||
room='general',
|
||||
message=ChatChannel.DjangoMessage(user='system', text='Hello')
|
||||
)
|
||||
Send a ServerMessage to every subscriber of the group named by the
|
||||
given params, from outside a subscription (views, tasks, signals).
|
||||
"""
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
@@ -219,16 +127,13 @@ class ReactChannel:
|
||||
)
|
||||
return
|
||||
|
||||
# Build params model if defined
|
||||
params_obj = None
|
||||
if cls.Params:
|
||||
params_obj = cls.Params(**params)
|
||||
|
||||
# Get group name
|
||||
instance = cls()
|
||||
group_name = instance.group(params_obj)
|
||||
|
||||
# Send to group
|
||||
await channel_layer.group_send(
|
||||
group_name,
|
||||
{
|
||||
@@ -241,63 +146,31 @@ class ReactChannel:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry
|
||||
# =============================================================================
|
||||
|
||||
_registry: dict[str, Type[ReactChannel]] = {}
|
||||
_registry: dict[str, Type[Channel]] = {}
|
||||
|
||||
|
||||
def register(channel_class: Type[ReactChannel], name: str) -> None:
|
||||
"""
|
||||
Register a channel.
|
||||
|
||||
Args:
|
||||
channel_class: The ReactChannel subclass to register
|
||||
name: URL-friendly name (used in subscriptions)
|
||||
"""
|
||||
def register(channel_class: Type[Channel], name: str) -> None:
|
||||
"""Register a channel class under a URL-friendly wire name."""
|
||||
if name in _registry:
|
||||
raise ValueError(f"Channel '{name}' is already registered")
|
||||
|
||||
channel_class._registered_name = name
|
||||
|
||||
# Validate the channel class
|
||||
if not hasattr(channel_class, "authorize"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||
if not hasattr(channel_class, "group"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||
|
||||
_registry[name] = channel_class
|
||||
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}")
|
||||
|
||||
|
||||
def get_channel(name: str) -> Type[ReactChannel] | None:
|
||||
def get_channel(name: str) -> Type[Channel] | None:
|
||||
"""Get a registered channel class by name."""
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
||||
"""Get all registered channel classes."""
|
||||
def get_registered_channels() -> dict[str, Type[Channel]]:
|
||||
"""Get a copy of the name -> channel-class registry."""
|
||||
return dict(_registry)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Consumer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_websocket_application():
|
||||
"""
|
||||
Get the WebSocket application for ASGI.
|
||||
|
||||
Usage in asgi.py:
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": channels.get_websocket_application(),
|
||||
})
|
||||
"""
|
||||
"""Build the ASGI application that serves every registered channel."""
|
||||
try:
|
||||
from channels.routing import URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
@@ -308,7 +181,7 @@ def get_websocket_application():
|
||||
"Install it with: pip install channels channels-redis"
|
||||
)
|
||||
|
||||
from .connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
return AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
@@ -319,42 +192,30 @@ def get_websocket_application():
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Export (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_channels_schema() -> dict:
|
||||
"""
|
||||
Get schema for all registered channels (for TypeScript generation).
|
||||
|
||||
Returns a dict suitable for the frontend code generator.
|
||||
"""
|
||||
"""JSON-schema per registered channel, keyed by wire name."""
|
||||
schema = {"channels": {}}
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
channel_schema = {
|
||||
"name": name,
|
||||
"params": None,
|
||||
"reactMessage": None,
|
||||
"djangoMessage": None,
|
||||
"clientMessage": None,
|
||||
"serverMessage": None,
|
||||
}
|
||||
|
||||
# Extract Params schema
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
if channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
if channel_class.ClientMessage:
|
||||
channel_schema[
|
||||
"reactMessage"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
"clientMessage"
|
||||
] = channel_class.ClientMessage.model_json_schema()
|
||||
|
||||
# Extract DjangoMessage schema
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
if channel_class.ServerMessage:
|
||||
channel_schema[
|
||||
"djangoMessage"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
"serverMessage"
|
||||
] = channel_class.ServerMessage.model_json_schema()
|
||||
|
||||
schema["channels"][name] = channel_schema
|
||||
|
||||
@@ -369,34 +230,37 @@ def _register_channel_schema_endpoint(
|
||||
input_cls: type | None,
|
||||
output_cls: type,
|
||||
) -> None:
|
||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||
"""
|
||||
Attach one operation to `api` whose annotations name `input_cls` and
|
||||
`output_cls`, so Ninja emits both into `components.schemas`.
|
||||
"""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
def schema_carrier(request, data):
|
||||
return output_cls.model_json_schema()
|
||||
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
schema_carrier.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
def schema_carrier(request):
|
||||
return output_cls.model_json_schema()
|
||||
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
schema_carrier
|
||||
)
|
||||
|
||||
|
||||
def get_channels_openapi_schema() -> dict:
|
||||
"""
|
||||
Get OpenAPI schema for all registered channels.
|
||||
OpenAPI document covering every registered channel's wire types, with the
|
||||
per-channel slot table under the `x-mizan-channels` extension key.
|
||||
|
||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
||||
This schema is consumed by openapi-typescript for type generation.
|
||||
Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation
|
||||
the Mizan IR emits, so the two documents name one type identically.
|
||||
"""
|
||||
from ninja import NinjaAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Create temporary Ninja API for schema generation only
|
||||
schema_api = NinjaAPI(
|
||||
title="mizan Channels",
|
||||
version="1.0.0",
|
||||
@@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict:
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
# Store dynamically created classes
|
||||
schema_classes: dict[str, type] = {}
|
||||
channel_metadata: list[dict] = []
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
pascal_name = name.replace("_", " ").title().replace(" ", "")
|
||||
pascal_name = wire_to_pascal(name)
|
||||
|
||||
channel_meta = {
|
||||
"name": name,
|
||||
"pascalName": pascal_name,
|
||||
"hasParams": False,
|
||||
"hasReactMessage": False,
|
||||
"hasDjangoMessage": False,
|
||||
"hasClientMessage": False,
|
||||
"hasServerMessage": False,
|
||||
}
|
||||
|
||||
# Register Params type
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
if channel_class.Params:
|
||||
params_name = f"{pascal_name}Params"
|
||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||
channel_meta["hasParams"] = True
|
||||
channel_meta["paramsType"] = params_name
|
||||
|
||||
# Create dummy endpoint to include in schema
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/params",
|
||||
@@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict:
|
||||
output_cls=BaseModel,
|
||||
)
|
||||
|
||||
# Register ReactMessage type
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
react_name = f"{pascal_name}ReactMessage"
|
||||
schema_classes[react_name] = type(
|
||||
react_name, (channel_class.ReactMessage,), {}
|
||||
if channel_class.ClientMessage:
|
||||
client_name = f"{pascal_name}ClientMessage"
|
||||
schema_classes[client_name] = type(
|
||||
client_name, (channel_class.ClientMessage,), {}
|
||||
)
|
||||
channel_meta["hasReactMessage"] = True
|
||||
channel_meta["reactMessageType"] = react_name
|
||||
channel_meta["hasClientMessage"] = True
|
||||
channel_meta["clientMessageType"] = client_name
|
||||
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/react",
|
||||
operation_id=f"{name}ReactMessage",
|
||||
summary=f"{pascal_name} React→Django message",
|
||||
input_cls=schema_classes[react_name],
|
||||
path=f"/channels/{name}/client",
|
||||
operation_id=f"{name}ClientMessage",
|
||||
summary=f"{pascal_name} client→server message",
|
||||
input_cls=schema_classes[client_name],
|
||||
output_cls=BaseModel,
|
||||
)
|
||||
|
||||
# Register DjangoMessage type
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
django_name = f"{pascal_name}DjangoMessage"
|
||||
schema_classes[django_name] = type(
|
||||
django_name, (channel_class.DjangoMessage,), {}
|
||||
if channel_class.ServerMessage:
|
||||
server_name = f"{pascal_name}ServerMessage"
|
||||
schema_classes[server_name] = type(
|
||||
server_name, (channel_class.ServerMessage,), {}
|
||||
)
|
||||
channel_meta["hasDjangoMessage"] = True
|
||||
channel_meta["djangoMessageType"] = django_name
|
||||
channel_meta["hasServerMessage"] = True
|
||||
channel_meta["serverMessageType"] = server_name
|
||||
|
||||
_register_channel_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/channels/{name}/django",
|
||||
operation_id=f"{name}DjangoMessage",
|
||||
summary=f"{pascal_name} Django→React message",
|
||||
path=f"/channels/{name}/server",
|
||||
operation_id=f"{name}ServerMessage",
|
||||
summary=f"{pascal_name} server→client message",
|
||||
input_cls=None,
|
||||
output_cls=schema_classes[django_name],
|
||||
output_cls=schema_classes[server_name],
|
||||
)
|
||||
|
||||
channel_metadata.append(channel_meta)
|
||||
|
||||
# Get OpenAPI schema from Ninja
|
||||
# path_prefix="" avoids URL reverse() — this API is never mounted
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add channel metadata extension
|
||||
schema["x-mizan-channels"] = channel_metadata
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Endpoint (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
_schema_router = None
|
||||
|
||||
|
||||
def _get_schema_router():
|
||||
"""Get the Ninja router for the channels schema endpoint."""
|
||||
global _schema_router
|
||||
if _schema_router is None:
|
||||
from ninja import Router
|
||||
@@ -502,17 +354,16 @@ def _get_schema_router():
|
||||
|
||||
@_schema_router.get("/schema/")
|
||||
def channels_schema(request):
|
||||
"""Get schema for all registered channels (for TypeScript generation)."""
|
||||
return get_channels_schema()
|
||||
|
||||
return _schema_router
|
||||
|
||||
|
||||
def get_urls():
|
||||
"""Get URL patterns for channels schema endpoint."""
|
||||
"""URL patterns serving the channels schema endpoint."""
|
||||
from ninja import NinjaAPI
|
||||
|
||||
api = NinjaAPI(urls_namespace="django_react_channels")
|
||||
api = NinjaAPI(urls_namespace="mizan_channels")
|
||||
api.add_router("/", _get_schema_router())
|
||||
return api.urls
|
||||
|
||||
@@ -523,17 +374,8 @@ def __getattr__(name):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Core Registry Extension
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _ChannelsExtension:
|
||||
"""
|
||||
Plugs the channel registry into mizan_core.registry as the 'channels'
|
||||
extension. Schema output goes under schema['channels'] in the unified
|
||||
registry export consumed by codegen.
|
||||
"""
|
||||
class _ChannelsExtension(RegistryExtension):
|
||||
"""Exposes the channel registry to mizan_core under the 'channels' key."""
|
||||
|
||||
def all(self) -> dict:
|
||||
return dict(_registry)
|
||||
@@ -546,13 +388,17 @@ class _ChannelsExtension:
|
||||
"type": "channel",
|
||||
"bidirectional": False,
|
||||
}
|
||||
if getattr(channel_class, "Params", None):
|
||||
if channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
if getattr(channel_class, "ReactMessage", None):
|
||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||
if channel_class.ClientMessage:
|
||||
channel_schema[
|
||||
"client_message"
|
||||
] = channel_class.ClientMessage.model_json_schema()
|
||||
channel_schema["bidirectional"] = True
|
||||
if getattr(channel_class, "DjangoMessage", None):
|
||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if channel_class.ServerMessage:
|
||||
channel_schema[
|
||||
"server_message"
|
||||
] = channel_class.ServerMessage.model_json_schema()
|
||||
out[name] = channel_schema
|
||||
return out
|
||||
|
||||
@@ -560,25 +406,15 @@ class _ChannelsExtension:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
from mizan_core.registry import register_extension as _register_extension
|
||||
_register_extension("channels", _ChannelsExtension())
|
||||
register_extension("channels", _ChannelsExtension())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exports
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# URLs
|
||||
"urls",
|
||||
# Base class
|
||||
"ReactChannel",
|
||||
# Registration
|
||||
"Channel",
|
||||
"register",
|
||||
"get_channel",
|
||||
"get_registered_channels",
|
||||
# ASGI application
|
||||
"get_websocket_application",
|
||||
# Schema export
|
||||
"get_channels_schema",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
WebSocket consumer for mizan.channels.
|
||||
|
||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||
WebSocket consumer multiplexing channel subscriptions and RPC calls over one
|
||||
socket.
|
||||
|
||||
Protocol:
|
||||
Browser sends:
|
||||
@@ -16,7 +15,7 @@ Protocol:
|
||||
|
||||
Server sends:
|
||||
# Channel messages
|
||||
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}}
|
||||
{"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}}
|
||||
|
||||
# RPC responses
|
||||
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
|
||||
@@ -25,15 +24,8 @@ Protocol:
|
||||
{"error": "..."}
|
||||
|
||||
Authentication:
|
||||
Supports both session (cookie) and JWT authentication:
|
||||
- Session: Handled automatically via AuthMiddlewareStack (cookies in handshake)
|
||||
- JWT: Pass token as query parameter: ws://...?token=<jwt>
|
||||
|
||||
The WebSocket URL for JWT auth would be: ws://localhost/ws/?token=<access_token>
|
||||
|
||||
Security:
|
||||
- Functions must be explicitly registered (no arbitrary code execution)
|
||||
- Pydantic validation runs BEFORE any function code
|
||||
Session cookies arrive through AuthMiddlewareStack during the handshake;
|
||||
a JWT arrives as a query parameter: ws://localhost/ws/?token=<access_token>
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -43,7 +35,8 @@ from urllib.parse import parse_qs
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
from . import get_channel
|
||||
|
||||
from mizan.channels import get_channel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,27 +44,23 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class WebSocketRequest:
|
||||
"""
|
||||
Minimal request adapter for WebSocket context.
|
||||
|
||||
Provides the interface expected by ServerFunction without full HttpRequest.
|
||||
This is intentionally minimal - only expose what's needed.
|
||||
|
||||
Note: Some Django libraries (e.g., allauth rate limiting) check request.method.
|
||||
We set method="POST" since WebSocket RPC calls are semantically similar to POST.
|
||||
The request surface ServerFunction reads, backed by a WebSocket scope
|
||||
instead of an HttpRequest.
|
||||
"""
|
||||
|
||||
# WebSocket RPC is semantically similar to POST (sends data, expects response)
|
||||
# Some Django libraries (allauth rate limiting) branch on request.method;
|
||||
# an RPC call carries data and expects a response, so POST is the match.
|
||||
method = "POST"
|
||||
|
||||
def __init__(self, scope: dict, channel_name: str = None):
|
||||
self.user = scope.get("user")
|
||||
self.session = scope.get("session", {})
|
||||
self.channel_name = channel_name # For push subscriptions
|
||||
self.channel_name = channel_name
|
||||
self._scope = scope
|
||||
|
||||
@property
|
||||
def META(self) -> dict:
|
||||
"""HTTP headers from WebSocket handshake."""
|
||||
"""HTTP headers from the WebSocket handshake, in WSGI key form."""
|
||||
headers = dict(self._scope.get("headers", []))
|
||||
return {
|
||||
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
|
||||
@@ -80,24 +69,15 @@ class WebSocketRequest:
|
||||
|
||||
|
||||
class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
Multiplexed WebSocket consumer for django_react channels.
|
||||
|
||||
Manages multiple channel subscriptions over a single WebSocket connection.
|
||||
|
||||
Authentication:
|
||||
- Session auth via cookies (handled by AuthMiddlewareStack)
|
||||
- JWT auth via query parameter: ws://...?token=<jwt>
|
||||
"""
|
||||
"""Holds every channel subscription opened over one WebSocket connection."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Track subscriptions: {(channel_name, params_json): channel_instance}
|
||||
# {(channel_name, params_json): channel_instance}
|
||||
self._subscriptions: dict[tuple[str, str], Any] = {}
|
||||
|
||||
async def connect(self):
|
||||
"""Accept the WebSocket connection, authenticating via JWT if provided."""
|
||||
# Check for JWT token in query parameters
|
||||
await self._try_jwt_auth()
|
||||
|
||||
await self.accept()
|
||||
@@ -107,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _try_jwt_auth(self):
|
||||
"""
|
||||
Attempt JWT authentication from query parameter.
|
||||
Authenticate from a ?token=<jwt> query parameter, building a JWTUser
|
||||
from the token claims with no database query.
|
||||
|
||||
If a valid JWT token is provided via ?token=<jwt>, authenticate the user
|
||||
using JWTUser (no database query).
|
||||
|
||||
Security: If JWT is provided but invalid, we log it but don't reject
|
||||
the connection - the session auth may still be valid. However, if JWT
|
||||
IS valid, it takes precedence over session auth.
|
||||
An invalid token leaves the scope untouched so session auth still
|
||||
applies; a valid one overwrites whatever session auth resolved.
|
||||
"""
|
||||
# Parse query string for token
|
||||
query_string = self.scope.get("query_string", b"").decode()
|
||||
params = parse_qs(query_string)
|
||||
token_list = params.get("token", [])
|
||||
|
||||
if not token_list:
|
||||
return # No JWT provided, use session auth
|
||||
return
|
||||
|
||||
token = token_list[0]
|
||||
if not token:
|
||||
return
|
||||
|
||||
# Validate JWT and create JWTUser (no DB query)
|
||||
try:
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
@@ -136,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||
if payload is None:
|
||||
logger.debug("JWT token invalid or expired")
|
||||
return # Fall back to session auth
|
||||
return
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
self.scope["user"] = JWTUser(payload)
|
||||
logger.debug(f"JWT auth successful for user {payload.user_id}")
|
||||
except Exception as e:
|
||||
@@ -157,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"WebSocket disconnected: {self.channel_name}")
|
||||
|
||||
async def receive_json(self, content: dict):
|
||||
"""Handle incoming JSON messages."""
|
||||
"""Route one incoming frame by its "action" field."""
|
||||
action = content.get("action")
|
||||
|
||||
if action == "subscribe":
|
||||
@@ -178,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
|
||||
async def _handle_subscribe(self, content: dict):
|
||||
"""Handle subscription request."""
|
||||
"""Authorize, join the group, and record the subscription."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
|
||||
# Get channel class
|
||||
channel_class = get_channel(channel_name)
|
||||
if not channel_class:
|
||||
await self.send_json(
|
||||
@@ -192,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create subscription key
|
||||
params_json = json.dumps(params_dict, sort_keys=True)
|
||||
sub_key = (channel_name, params_json)
|
||||
|
||||
# Check if already subscribed
|
||||
if sub_key in self._subscriptions:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -207,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create channel instance
|
||||
instance = channel_class()
|
||||
instance.user = self.scope.get("user")
|
||||
instance._channel_layer = self.channel_layer
|
||||
@@ -215,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
instance._registered_name = channel_name
|
||||
instance._params_dict = params_dict
|
||||
|
||||
# Parse params
|
||||
params_obj = None
|
||||
if channel_class.Params:
|
||||
try:
|
||||
@@ -229,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Check authorization
|
||||
try:
|
||||
if params_obj:
|
||||
authorized = instance.authorize(params_obj)
|
||||
@@ -254,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Get group and join
|
||||
try:
|
||||
if params_obj:
|
||||
group_name = instance.group(params_obj)
|
||||
@@ -271,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Store subscription
|
||||
self._subscriptions[sub_key] = instance
|
||||
|
||||
# Call on_connect hook
|
||||
try:
|
||||
await instance.on_connect(params_obj)
|
||||
except Exception as e:
|
||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||
|
||||
# Confirm subscription
|
||||
await self.send_json(
|
||||
{
|
||||
"subscribed": True,
|
||||
@@ -292,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||
|
||||
async def _handle_unsubscribe(self, content: dict):
|
||||
"""Handle unsubscription request."""
|
||||
"""Drop the subscription and leave its groups."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
|
||||
@@ -318,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.debug(f"Unsubscribed from {channel_name}")
|
||||
|
||||
async def _handle_message(self, content: dict):
|
||||
"""Handle incoming message from browser."""
|
||||
"""Validate a ClientMessage, hand it to receive(), broadcast what comes back."""
|
||||
channel_name = content.get("channel")
|
||||
params_dict = content.get("params", {})
|
||||
data = content.get("data", {})
|
||||
@@ -338,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
channel_class = instance.__class__
|
||||
|
||||
# Check if channel accepts messages
|
||||
if not channel_class.ReactMessage:
|
||||
if not channel_class.ClientMessage:
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
@@ -348,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Parse message
|
||||
try:
|
||||
msg = channel_class.ReactMessage(**data)
|
||||
msg = channel_class.ClientMessage(**data)
|
||||
except Exception as e:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -360,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Parse params
|
||||
params_obj = None
|
||||
if channel_class.Params:
|
||||
params_obj = channel_class.Params(**params_dict)
|
||||
|
||||
# Handle message
|
||||
try:
|
||||
response = instance.receive(params_obj, msg)
|
||||
|
||||
# If handler returned a message, broadcast it
|
||||
if response is not None:
|
||||
if params_obj:
|
||||
group_name = instance.group(params_obj)
|
||||
@@ -389,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _handle_rpc(self, content: dict):
|
||||
"""
|
||||
Handle RPC (server function) call.
|
||||
Run a registered server function.
|
||||
|
||||
Protocol:
|
||||
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
|
||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
Security:
|
||||
- Only functions with @client(websocket=True) are allowed
|
||||
- Pydantic validation happens BEFORE any function code runs
|
||||
- Function must be explicitly registered (no arbitrary code execution)
|
||||
- User context from WebSocket session is passed to function
|
||||
Only functions registered with @client(websocket=True) are reachable,
|
||||
and execute_function validates args against the function's Input model
|
||||
before any function body runs.
|
||||
"""
|
||||
from mizan.client.executor import execute_function, FunctionError
|
||||
from mizan_core.registry import get_function
|
||||
@@ -409,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
fn_name = content.get("fn")
|
||||
args = content.get("args", {})
|
||||
|
||||
# Validate request structure
|
||||
if not request_id:
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -431,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Check if function exists and has websocket=True
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await self.send_json(
|
||||
@@ -446,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Only allow functions explicitly marked with websocket=True
|
||||
fn_meta = getattr(fn_class, "_meta", {})
|
||||
if not fn_meta.get("websocket"):
|
||||
await self.send_json(
|
||||
@@ -461,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
return
|
||||
|
||||
# Create request adapter from WebSocket scope
|
||||
ws_request = WebSocketRequest(
|
||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||
)
|
||||
|
||||
# Execute function (Pydantic validation happens inside execute_function)
|
||||
# This is sync, so we need to run it in a thread pool
|
||||
# execute_function is sync, so it runs in a thread pool
|
||||
result = await sync_to_async(execute_function, thread_sensitive=True)(
|
||||
ws_request,
|
||||
fn_name,
|
||||
args,
|
||||
)
|
||||
|
||||
# Send response
|
||||
if isinstance(result, FunctionError):
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -488,8 +439,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
}
|
||||
)
|
||||
else:
|
||||
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a
|
||||
# mutation sent over the socket invalidates exactly as one sent over HTTP
|
||||
# the {result, invalidate, merge} envelope the HTTP RPC path builds
|
||||
from mizan.client.executor import _resolve_invalidation, _resolve_merges
|
||||
|
||||
data = {"result": result.data}
|
||||
@@ -508,16 +458,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def _handle_ctx(self, content: dict):
|
||||
"""
|
||||
Handle a context-bundle fetch.
|
||||
Fetch a context bundle through execute_context.
|
||||
|
||||
Protocol:
|
||||
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
|
||||
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
|
||||
or: {"id": "request-id", "ok": false, "error": {...}}
|
||||
|
||||
Dispatches through the same execute_context the HTTP view calls. That view's
|
||||
origin-side cache is a CDN concern with nothing in front of a socket, so it is not
|
||||
part of this path.
|
||||
"""
|
||||
from mizan.client.executor import execute_context, FunctionError
|
||||
|
||||
@@ -563,10 +509,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def channel_message(self, event: dict):
|
||||
"""
|
||||
Handle messages broadcast to a group.
|
||||
|
||||
Called when channel_layer.group_send() is used.
|
||||
Includes channel name and params so the client can route the message.
|
||||
Forward a group broadcast down the socket, carrying the channel name
|
||||
and params the client routes on.
|
||||
"""
|
||||
await self.send_json(
|
||||
{
|
||||
@@ -579,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def push_message(self, event: dict):
|
||||
"""
|
||||
Handle push messages from server functions.
|
||||
Forward a topic push down the socket.
|
||||
|
||||
Called when push("topic", data) is used from a server function.
|
||||
The client receives this to update its local state.
|
||||
|
||||
Protocol:
|
||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
Wire shape: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
"""
|
||||
await self.send_json(
|
||||
{
|
||||
|
||||
@@ -1,68 +1,47 @@
|
||||
"""
|
||||
mizan Push - Server-initiated messages to clients.
|
||||
Topic-based server-initiated messages.
|
||||
|
||||
Simple API for pushing data to subscribed WebSocket connections.
|
||||
|
||||
Usage:
|
||||
# In a server function - push to all subscribers
|
||||
from mizan.push import push
|
||||
|
||||
push("room:42", {"type": "new_message", "data": {...}})
|
||||
|
||||
# Subscribe a connection to a topic (call during context fetch)
|
||||
from mizan.push import subscribe
|
||||
|
||||
subscribe(request, "room:42")
|
||||
A topic string ("room:42", "user:123:notifications") maps onto one channel
|
||||
layer group; subscribing a connection adds its channel name to that group,
|
||||
and pushing sends a "push.message" event to every member.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Lazy import to avoid import errors when channels is not installed
|
||||
# (e.g., during schema generation)
|
||||
if TYPE_CHECKING:
|
||||
from channels.layers import BaseChannelLayer
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
"""Get channel layer, returning None if channels is not installed."""
|
||||
def _get_channel_layer():
|
||||
"""The configured channel layer, or None when django-channels is absent."""
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
return get_channel_layer()
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.warning("django-channels is not installed, push is inert: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _async_to_sync(coro):
|
||||
"""Wrapper for async_to_sync that handles missing channels."""
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
return async_to_sync(coro)
|
||||
return get_channel_layer()
|
||||
|
||||
|
||||
def get_topic_group_name(topic: str) -> str:
|
||||
"""Convert a topic string to a valid channel layer group name."""
|
||||
# Channel layer group names must be valid ASCII alphanumeric + hyphens/underscores/periods
|
||||
# Replace colons with underscores
|
||||
"""
|
||||
Convert a topic to a channel layer group name. Group names allow ASCII
|
||||
alphanumerics plus hyphens, underscores and periods, so the topic
|
||||
separator becomes an underscore.
|
||||
"""
|
||||
return topic.replace(":", "_")
|
||||
|
||||
|
||||
def subscribe(request, topic: str) -> None:
|
||||
"""
|
||||
Subscribe this WebSocket connection to a topic.
|
||||
Add this WebSocket connection to a topic's group.
|
||||
|
||||
Call this in a context or server function to register the connection
|
||||
for push notifications on the given topic.
|
||||
|
||||
Args:
|
||||
request: The Django request (must have channel_name attribute from WebSocket)
|
||||
topic: Topic string, e.g., "room:42", "user:123:notifications"
|
||||
An HTTP request carries no channel_name, so there is nothing to add.
|
||||
"""
|
||||
channel_name = getattr(request, "channel_name", None)
|
||||
if not channel_name:
|
||||
# HTTP request, not WebSocket - can't subscribe
|
||||
return
|
||||
|
||||
channel_layer = _get_channel_layer()
|
||||
@@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None:
|
||||
return
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
_async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||
async_to_sync(channel_layer.group_add)(group_name, channel_name)
|
||||
|
||||
|
||||
def unsubscribe(request, topic: str) -> None:
|
||||
"""
|
||||
Unsubscribe this WebSocket connection from a topic.
|
||||
|
||||
Args:
|
||||
request: The Django request (must have channel_name attribute from WebSocket)
|
||||
topic: Topic string to unsubscribe from
|
||||
"""
|
||||
"""Remove this WebSocket connection from a topic's group."""
|
||||
channel_name = getattr(request, "channel_name", None)
|
||||
if not channel_name:
|
||||
return
|
||||
@@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None:
|
||||
return
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
_async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||
async_to_sync(channel_layer.group_discard)(group_name, channel_name)
|
||||
|
||||
|
||||
def push(topic: str, data: dict | BaseModel) -> None:
|
||||
"""
|
||||
Push data to all connections subscribed to a topic.
|
||||
|
||||
Args:
|
||||
topic: Topic string, e.g., "room:42"
|
||||
data: Data to send (dict or Pydantic model)
|
||||
|
||||
Example:
|
||||
push("room:42", {
|
||||
"type": "new_message",
|
||||
"message": {"id": 1, "text": "Hello", "user": "alice@example.com"}
|
||||
})
|
||||
"""
|
||||
"""Send data to every connection subscribed to a topic."""
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
logger.warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
return
|
||||
|
||||
# Convert Pydantic model to dict if needed
|
||||
if isinstance(data, BaseModel):
|
||||
data = data.model_dump()
|
||||
|
||||
group_name = get_topic_group_name(topic)
|
||||
|
||||
_async_to_sync(channel_layer.group_send)(
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
group_name,
|
||||
{
|
||||
"type": "push.message", # Maps to push_message handler in consumer
|
||||
# The event's "type" selects the consumer method of the same name,
|
||||
# with dots translated to underscores.
|
||||
"type": "push.message",
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
},
|
||||
@@ -133,9 +93,12 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
|
||||
|
||||
async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||
"""Async version of push for use in async contexts."""
|
||||
"""Send data to every connection subscribed to a topic, from the event loop."""
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
logger.warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(data, BaseModel):
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"""
|
||||
mizan.client - Server function implementation.
|
||||
|
||||
This subpackage contains everything needed to make server functions work:
|
||||
- The @client decorator (lives in mizan_core.client.function)
|
||||
- ServerFunction base class (mizan_core.client.function)
|
||||
- Function execution logic (.executor — Django-specific dispatch)
|
||||
- JWT authentication (.jwt — Django-specific session integration)
|
||||
|
||||
Usage:
|
||||
from mizan.client import client, ServerFunction, compose
|
||||
The server-function surface: the `client` decorator and `ServerFunction` base
|
||||
come from `mizan_core`; execution and dispatch are Django-specific and live in
|
||||
`mizan.client.executor`.
|
||||
"""
|
||||
|
||||
# Register the Django framework response base so view-path detection works
|
||||
# in mizan_core.client.function. Has to happen before any @client-decorated
|
||||
# code is evaluated.
|
||||
# Registering the Django response base has to happen before any
|
||||
# @client-decorated code is evaluated, or view-path detection in
|
||||
# mizan_core.client.function cannot recognize a returned HttpResponse.
|
||||
from django.http import HttpResponseBase as _HttpResponseBase
|
||||
from mizan_core.client.function import set_framework_response_base as _set_response_base
|
||||
_set_response_base(_HttpResponseBase)
|
||||
@@ -39,7 +32,7 @@ from mizan_core.client.function import (
|
||||
create_form_functions,
|
||||
)
|
||||
|
||||
from .executor import (
|
||||
from mizan.client.executor import (
|
||||
execute_function,
|
||||
function_call_view,
|
||||
ErrorCode,
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
"""
|
||||
mizan Function Executor
|
||||
Dispatch for registered server functions over HTTP.
|
||||
|
||||
Handles execution of server functions.
|
||||
This is the core of the "Server Functions" feature - callable from React
|
||||
without REST boilerplate.
|
||||
|
||||
Security model:
|
||||
- All input validated against Pydantic schema BEFORE execution
|
||||
- Authentication: JWT (stateless) or Session (stateful) - auto-detected
|
||||
- JWT: Authorization header with Bearer token (no CSRF needed)
|
||||
- Session: Cookie-based with CSRF token (via X-CSRFToken header)
|
||||
- WebSocket RPC uses Origin header checking instead
|
||||
- No implicit function exposure - must be explicitly registered
|
||||
Input is validated against the function's Pydantic Input before the body ever
|
||||
runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or
|
||||
an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF;
|
||||
anything else falls through to session auth with CSRF enforced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,7 +14,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
@@ -31,9 +24,6 @@ from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
from mizan_core.registry import get_function, get_context_groups
|
||||
from mizan.setup.settings import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -99,50 +89,40 @@ def _check_auth_requirement(
|
||||
auth_requirement: str | Callable | None,
|
||||
) -> FunctionError | None:
|
||||
"""
|
||||
Check if the request meets the auth requirement.
|
||||
Test `request` against an auth requirement of 'required', 'staff',
|
||||
'superuser', a callable, or None. Returns a FunctionError on failure.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest (with user set)
|
||||
auth_requirement: 'required', 'staff', 'superuser', callable, or None
|
||||
|
||||
Returns:
|
||||
FunctionError if auth check fails, None if it passes.
|
||||
|
||||
Note: This uses request.user which may be a JWTUser (stateless) or
|
||||
Django User (from session). Either way, no additional DB query is made
|
||||
for the built-in checks. Custom callables may query DB if they choose.
|
||||
The built-in checks read only flags already on request.user — a JWTUser or
|
||||
a session User — so none of them hit the database. A callable may.
|
||||
"""
|
||||
if auth_requirement is None:
|
||||
return None
|
||||
|
||||
user = request.user
|
||||
|
||||
# Handle callable auth
|
||||
if callable(auth_requirement):
|
||||
try:
|
||||
result = auth_requirement(request)
|
||||
if result:
|
||||
return None # Authorized
|
||||
return None
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Access denied",
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Custom error message from the callable
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Access denied",
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
# Every string-based requirement implies authentication.
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
@@ -150,7 +130,6 @@ def _check_auth_requirement(
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
@@ -168,7 +147,7 @@ def _purge_cache_for_invalidation(
|
||||
invalidate: list,
|
||||
request: HttpRequest | None = None,
|
||||
) -> None:
|
||||
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
|
||||
"""Purge origin-side cache entries for invalidation targets, scoped by user when known."""
|
||||
cache = get_cache()
|
||||
if cache is None:
|
||||
return
|
||||
@@ -199,34 +178,27 @@ def _purge_cache_for_invalidation(
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Determine whether an affects target is a context name or function name.
|
||||
Classify an affects target as a context or a function inside one.
|
||||
|
||||
Returns:
|
||||
("context", "user", None) — full context invalidation
|
||||
("function", "user_profile", "user") — function within context
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
|
||||
# Check if it's a context name directly
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
|
||||
# Check if it's a function name within a context
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
|
||||
# Not a context or context function — treat as context name anyway
|
||||
# (it might be a non-context function or an as-yet-unregistered context)
|
||||
# An unregistered name is treated as a context so invalidation still
|
||||
# propagates rather than being silently dropped.
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _get_context_param_names(context_name: str) -> set[str]:
|
||||
"""
|
||||
Get the set of parameter names used by functions in a context.
|
||||
|
||||
Returns the union of all Input field names across context functions.
|
||||
"""
|
||||
"""Union of the Input field names across every function in a context."""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
param_names: set[str] = set()
|
||||
@@ -247,18 +219,14 @@ def _resolve_invalidation(
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve invalidation targets with three-tier auto-scoping.
|
||||
Turn a mutation's `affects` metadata into invalidation targets, returning
|
||||
None when there is nothing to invalidate.
|
||||
|
||||
Tier 1: Argument name matching — if the mutation's input args overlap
|
||||
with the context's params by name, auto-scope.
|
||||
Tier 2: Auth inference — Edge-side concern, not handled here.
|
||||
Tier 3: Broad fallback — invalidate all instances.
|
||||
A target is scoped to specific params when the mutation's input argument
|
||||
names overlap the context's param names; otherwise the whole context is
|
||||
invalidated. A function-level target is keyed by the function name.
|
||||
|
||||
Also handles function-level targeting: affects='user_profile' resolves
|
||||
to the function name (v1: runtime refetches the whole context anyway).
|
||||
|
||||
Returns a list suitable for both JSON body and header serialization.
|
||||
Returns None if no invalidation needed.
|
||||
The returned list serializes into both the JSON body and the header.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
@@ -275,7 +243,6 @@ def _resolve_invalidation(
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
# Function-level: use the function name as the invalidation key
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
@@ -284,11 +251,9 @@ def _resolve_invalidation(
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
# Resolve the context this target belongs to (for param lookup)
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
# Tier 1: argument name matching
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _get_context_param_names(ctx_for_params)
|
||||
matched = {
|
||||
@@ -299,7 +264,6 @@ def _resolve_invalidation(
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
# Tier 3: broad fallback
|
||||
result.append(target_name)
|
||||
|
||||
return result if result else None
|
||||
@@ -311,15 +275,12 @@ def _resolve_merges(
|
||||
result_data: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""
|
||||
Resolve merge targets from @client(merge=...).
|
||||
|
||||
Each entry is `{context, slot, value, params?}` — `slot` is the
|
||||
function-name inside the context bundle the value lands in, resolved
|
||||
server-side by matching the mutation's return type against each
|
||||
context-function's return type. Kernel does no shape inference.
|
||||
|
||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||
Entries whose slot can't be uniquely resolved are dropped.
|
||||
Turn a mutation's `merge` metadata into `{context, slot, value, params?}`
|
||||
entries. `slot` is the function-name inside the context bundle the value
|
||||
lands in, resolved here by matching the mutation's declared Output against
|
||||
each context-function's Output. Entries whose slot is ambiguous are
|
||||
dropped, and params are scoped the same way `_resolve_invalidation` scopes
|
||||
them.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
@@ -358,7 +319,7 @@ def _resolve_merges(
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||
"""Find the one function in `context_name` whose Output matches the mutation's, if unique."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
groups = get_context_groups()
|
||||
@@ -378,18 +339,17 @@ def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
Format invalidation targets as the X-Mizan-Invalidate header value:
|
||||
comma-separated contexts, each optionally followed by semicolon-separated
|
||||
`key=value` params. Keys and values are URL-encoded so a param can never
|
||||
contain a delimiter.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
Param values are URL-encoded to prevent delimiter collisions.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
→ "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}]
|
||||
→ "search;q=hello%20world"
|
||||
→ "search;q=hello%20world"
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -417,22 +377,17 @@ def execute_function(
|
||||
input_data: dict[str, Any] | None = None,
|
||||
) -> "FunctionResult | FunctionError | HttpResponseBase":
|
||||
"""
|
||||
Execute a registered server function.
|
||||
Look up, authorize, validate, and run a registered server function.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest
|
||||
fn_name: Name of the registered function
|
||||
input_data: Input data to pass to the function
|
||||
|
||||
Returns:
|
||||
FunctionResult on success, FunctionError on failure
|
||||
Returns the function's HttpResponse untouched when it returned one,
|
||||
otherwise a FunctionResult or FunctionError.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
# Look up the function by name
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
# In DEBUG mode, include the name for easier debugging
|
||||
# Naming the missing function is a debugging aid, not something to
|
||||
# hand an unauthenticated caller in production.
|
||||
if settings.DEBUG:
|
||||
message = f"Function '{fn_name}' not found"
|
||||
else:
|
||||
@@ -442,7 +397,6 @@ def execute_function(
|
||||
message=message,
|
||||
)
|
||||
|
||||
# Reject private functions from RPC dispatch
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
if meta.get("private"):
|
||||
return FunctionError(
|
||||
@@ -450,36 +404,28 @@ def execute_function(
|
||||
message="Function is not client-callable",
|
||||
)
|
||||
|
||||
# Check auth requirement BEFORE executing
|
||||
# Auth is checked before the function body ever runs.
|
||||
auth_requirement = meta.get("auth")
|
||||
auth_error = _check_auth_requirement(request, auth_requirement)
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
|
||||
# Instantiate the view with the request
|
||||
view = view_class(request)
|
||||
|
||||
# Check if this is a form function that handles input specially
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
is_form_multipart = meta.get("multipart", False)
|
||||
|
||||
# For form functions with Input=None, skip Pydantic validation
|
||||
# The form itself handles validation
|
||||
input_cls = view.Input
|
||||
if input_cls is None and is_form_multipart:
|
||||
# Form function - pass input_data directly (already parsed by view or will be)
|
||||
# Form functions carry Input=None; the Django form owns validation.
|
||||
validated_input = input_data
|
||||
elif input_cls is BaseModel:
|
||||
has_input = False
|
||||
validated_input = None
|
||||
else:
|
||||
# Check if it has any fields defined
|
||||
has_input = bool(input_cls.model_fields) if input_cls else False
|
||||
|
||||
# Validate input against Pydantic schema
|
||||
try:
|
||||
if input_data:
|
||||
# Ensure input_data is a dict (not array or other type)
|
||||
if not isinstance(input_data, dict):
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
@@ -488,11 +434,11 @@ def execute_function(
|
||||
)
|
||||
validated_input = input_cls(**input_data)
|
||||
elif has_input:
|
||||
# Check if function requires input fields
|
||||
input_schema = input_cls.model_json_schema()
|
||||
required_fields = input_schema.get("required", [])
|
||||
if required_fields:
|
||||
# Format as field errors for consistency
|
||||
# Shaped like Pydantic's own field errors so the client
|
||||
# has one error format to handle.
|
||||
errors = {field: ["Field required"] for field in required_fields}
|
||||
return FunctionError(
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
@@ -501,10 +447,8 @@ def execute_function(
|
||||
)
|
||||
validated_input = input_cls()
|
||||
else:
|
||||
# No input expected, create empty model
|
||||
validated_input = None
|
||||
except ValidationError as e:
|
||||
# Convert Pydantic errors to our format
|
||||
errors = {}
|
||||
for error in e.errors():
|
||||
field = ".".join(str(loc) for loc in error["loc"])
|
||||
@@ -518,7 +462,6 @@ def execute_function(
|
||||
details={"fields": errors},
|
||||
)
|
||||
|
||||
# Execute the function
|
||||
try:
|
||||
output = view.call(validated_input)
|
||||
except NotImplementedError as e:
|
||||
@@ -528,28 +471,24 @@ def execute_function(
|
||||
message=str(e),
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Functions can raise PermissionError for auth issues
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Permission denied",
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the full exception for debugging
|
||||
logger.exception(f"Error executing function {fn_name}")
|
||||
return FunctionError(
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
message="An internal error occurred",
|
||||
# Don't expose internal details in production
|
||||
# Internals are only named when debug logging is already on.
|
||||
details={"type": type(e).__name__}
|
||||
if logger.isEnabledFor(logging.DEBUG)
|
||||
else None,
|
||||
)
|
||||
|
||||
# Return-type branching: HttpResponse (view path) vs data (RPC path)
|
||||
from django.http import HttpResponseBase
|
||||
|
||||
if isinstance(output, HttpResponseBase):
|
||||
# View path — add invalidation header + purge origin cache
|
||||
invalidate = _resolve_invalidation(view_class, input_data)
|
||||
if invalidate:
|
||||
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
|
||||
@@ -557,9 +496,8 @@ def execute_function(
|
||||
output["Cache-Control"] = "no-store"
|
||||
return output
|
||||
|
||||
# RPC path — serialize output. to_jsonable_python walks BaseModel /
|
||||
# list / dict recursively, so list[BaseModel] (and nested shapes) come
|
||||
# out wire-ready without a per-shape branch.
|
||||
# to_jsonable_python walks BaseModel / list / dict recursively, so nested
|
||||
# shapes need no per-shape branch here.
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
return FunctionResult(data=to_jsonable_python(output))
|
||||
@@ -567,10 +505,9 @@ def execute_function(
|
||||
|
||||
def _try_mwt_auth(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Attempt to authenticate the request using MWT (Mizan Web Token).
|
||||
|
||||
Checks the X-Mizan-Token header. If present and valid, sets request.user
|
||||
to an MWTUser. Returns True on success, False if no MWT header or invalid.
|
||||
Authenticate from the X-Mizan-Token header, setting request.user to an
|
||||
MWTUser on success. False means no header, no configured secret, or a
|
||||
token that did not verify.
|
||||
"""
|
||||
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
|
||||
if not token:
|
||||
@@ -607,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool:
|
||||
|
||||
def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Attempt to authenticate the request using JWT.
|
||||
|
||||
If Authorization header contains a valid Bearer token, authenticates
|
||||
the request and sets request.user to a JWTUser. Returns True if JWT
|
||||
auth succeeded.
|
||||
|
||||
IMPORTANT: This is stateless - no database query is made. The JWTUser
|
||||
object is created from the token claims. If you need the full User
|
||||
object, query it explicitly in your function.
|
||||
|
||||
Security: If JWT is provided but invalid, we return False and do NOT
|
||||
fall back to session auth. The caller should reject the request.
|
||||
Authenticate from an Authorization Bearer token, setting request.user to a
|
||||
JWTUser built from the claims — no database query. False means no bearer
|
||||
header or a token that did not verify; the caller must then reject rather
|
||||
than fall back to session auth.
|
||||
"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
@@ -636,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
if payload is None:
|
||||
return False
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
request.user = JWTUser(payload)
|
||||
request._mizan_jwt_authenticated = True
|
||||
return True
|
||||
except Exception:
|
||||
logging.getLogger("mizan.jwt").warning(
|
||||
"JWT authentication failed unexpectedly", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -652,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool:
|
||||
|
||||
def _csrf_protect_unless_token(view_func):
|
||||
"""
|
||||
Decorator that applies CSRF protection unless token auth is used.
|
||||
|
||||
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
|
||||
Both are self-authenticating, so CSRF protection is not needed.
|
||||
|
||||
Security: If a token is provided but invalid, reject the request - do NOT
|
||||
fall back to session auth.
|
||||
Wrap a view so CSRF applies only on the session path. MWT is checked
|
||||
first, then JWT; both are self-authenticating. A token that is present but
|
||||
invalid rejects the request outright rather than falling back to session
|
||||
auth.
|
||||
"""
|
||||
csrf_protected_view = csrf_protect(view_func)
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -673,7 +600,6 @@ def _csrf_protect_unless_token(view_func):
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -682,7 +608,6 @@ def _csrf_protect_unless_token(view_func):
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
|
||||
# No token — session auth with CSRF
|
||||
return csrf_protected_view(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -691,52 +616,25 @@ def _csrf_protect_unless_token(view_func):
|
||||
@_csrf_protect_unless_token
|
||||
def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
"""
|
||||
Django view for handling function calls (HTTP fallback for WebSocket RPC).
|
||||
POST endpoint for server-function calls.
|
||||
|
||||
Authentication (auto-detected):
|
||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
||||
A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries
|
||||
`fn` as a form field alongside the form's own fields, and its parsed data
|
||||
and files are attached to the request for the form function to pick up.
|
||||
|
||||
Endpoint: POST /api/mizan/call/
|
||||
|
||||
Request body (JSON):
|
||||
{
|
||||
"fn": "function_name", // Function name
|
||||
"args": { ... } // Optional, depending on function
|
||||
}
|
||||
|
||||
Request body (multipart/form-data for form submit functions):
|
||||
fn: function_name
|
||||
<field>: <value>
|
||||
...
|
||||
|
||||
Response on success:
|
||||
{
|
||||
"error": false,
|
||||
"data": { ... } // Function output
|
||||
}
|
||||
|
||||
Response on error:
|
||||
{
|
||||
"error": true,
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Input validation failed",
|
||||
"details": { ... }
|
||||
}
|
||||
Success answers `{"result": ...}`, plus `invalidate` / `merge` when the
|
||||
function declared them; failure answers the FunctionError shape.
|
||||
"""
|
||||
# Only allow POST
|
||||
if request.method != "POST":
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Only POST method allowed",
|
||||
).to_response(status=405)
|
||||
|
||||
# Check content type to determine parsing method
|
||||
content_type = request.content_type or ""
|
||||
is_multipart = content_type.startswith("multipart/form-data")
|
||||
|
||||
if is_multipart:
|
||||
# Multipart form data - used by form submit functions
|
||||
fn_name = request.POST.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -744,15 +642,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Missing 'fn' field",
|
||||
).to_response()
|
||||
|
||||
# Get form data (excluding 'fn')
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
try:
|
||||
if request.body:
|
||||
body = json.loads(request.body)
|
||||
@@ -767,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Invalid JSON in request body",
|
||||
).to_response()
|
||||
|
||||
# Extract function name and args
|
||||
fn_name = body.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -777,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
input_data = body.get("args")
|
||||
|
||||
# Execute the function
|
||||
result = execute_function(request, fn_name, input_data)
|
||||
|
||||
# View path — function returned an HttpResponse directly
|
||||
# The function returned an HttpResponse directly.
|
||||
from django.http import HttpResponseBase
|
||||
if isinstance(result, HttpResponseBase):
|
||||
return result
|
||||
|
||||
# Return appropriate response
|
||||
if isinstance(result, FunctionError):
|
||||
status = {
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
@@ -798,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
}.get(result.code, 400)
|
||||
return result.to_response(status=status)
|
||||
|
||||
# RPC path — build response with server-driven invalidation
|
||||
view_class = get_function(fn_name)
|
||||
response_data = {"result": result.data}
|
||||
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||
@@ -825,18 +716,8 @@ def execute_context(
|
||||
params: dict[str, str],
|
||||
) -> FunctionResult | FunctionError:
|
||||
"""
|
||||
Execute all functions in a named context with merged params.
|
||||
|
||||
Each function receives only the params it declares in its Input schema.
|
||||
If any function fails (auth, validation, execution), the entire request fails.
|
||||
|
||||
Args:
|
||||
request: The Django HttpRequest
|
||||
context_name: Name of the context (e.g., 'user', 'global')
|
||||
params: Query parameters (strings — Pydantic coerces types)
|
||||
|
||||
Returns:
|
||||
FunctionResult with bundled data, or FunctionError
|
||||
Run every function in a named context, handing each only the params it
|
||||
declares in its Input schema. The first failure aborts the whole bundle.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name)
|
||||
@@ -852,7 +733,6 @@ def execute_context(
|
||||
if view_class is None:
|
||||
continue
|
||||
|
||||
# Filter params to only those in this function's Input schema
|
||||
input_cls = getattr(view_class, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and input_cls.model_fields:
|
||||
fn_params = {
|
||||
@@ -871,13 +751,9 @@ def execute_context(
|
||||
|
||||
|
||||
def _jwt_auth_only(view_func):
|
||||
"""
|
||||
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
|
||||
Checks MWT first, then legacy JWT.
|
||||
"""
|
||||
"""Token auth for GET views: MWT first, then JWT. GET needs no CSRF."""
|
||||
@wraps(view_func)
|
||||
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||
# MWT takes priority
|
||||
if _has_mwt_header(request):
|
||||
if _try_mwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -886,7 +762,6 @@ def _jwt_auth_only(view_func):
|
||||
message="Invalid or expired MWT",
|
||||
).to_response(status=401)
|
||||
|
||||
# Legacy JWT fallback
|
||||
if _has_jwt_header(request):
|
||||
if _try_jwt_auth(request):
|
||||
return view_func(request, *args, **kwargs)
|
||||
@@ -895,7 +770,6 @@ def _jwt_auth_only(view_func):
|
||||
message="Invalid or expired JWT token",
|
||||
).to_response(status=401)
|
||||
|
||||
# No token — session auth (no CSRF needed for GET)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -904,18 +778,12 @@ def _jwt_auth_only(view_func):
|
||||
@_jwt_auth_only
|
||||
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
"""
|
||||
Fetch all functions in a named context in a single bundled GET request.
|
||||
GET endpoint answering every function in a named context as one bundle
|
||||
keyed by function name, with query params fanned out to each.
|
||||
|
||||
Endpoint: GET /api/mizan/ctx/<context_name>/?param1=val1¶m2=val2
|
||||
|
||||
Response: raw bundled data, CDN-cacheable.
|
||||
{
|
||||
"user_profile": { ... },
|
||||
"user_orders": [ ... ]
|
||||
}
|
||||
|
||||
Headers:
|
||||
Cache-Control: public, max-age=0, s-maxage=31536000
|
||||
The context's effective cache policy and revision are the strictest across
|
||||
its functions: any function declaring cache=False disables caching for the
|
||||
whole bundle, and the shortest declared TTL wins.
|
||||
"""
|
||||
if request.method != "GET":
|
||||
return FunctionError(
|
||||
@@ -925,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
|
||||
params = request.GET.dict()
|
||||
|
||||
# Resolve effective rev and cache policy across all functions in this context
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
@@ -947,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
else:
|
||||
effective_cache = min(effective_cache, fn_cache)
|
||||
|
||||
# Origin-side cache lookup (skip if cache=False)
|
||||
cache_backend = get_cache()
|
||||
cache_settings = get_settings()
|
||||
user_id = None
|
||||
@@ -988,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
|
||||
error_response["Cache-Control"] = "no-store"
|
||||
return error_response
|
||||
|
||||
# Deterministic JSON (sorted keys) for consistent cache keys
|
||||
# Sorted keys keep the serialized body byte-identical for a given result,
|
||||
# which is what makes it usable as a cache entry.
|
||||
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
|
||||
|
||||
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
|
||||
# The browser and non-Mizan intermediaries must not cache.
|
||||
# Caching happens in the origin cache below and at the edge, both of which
|
||||
# can be purged; a browser cache cannot, so it must not hold this.
|
||||
response["Cache-Control"] = "no-store"
|
||||
|
||||
# Store in origin-side cache (skip if cache=False)
|
||||
if use_cache:
|
||||
try:
|
||||
cache_put(
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
"""
|
||||
mizan.client.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
- JWT authentication utilities for validating tokens
|
||||
|
||||
Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
Token and settings names from `mizan.jwt`, re-exported under `mizan.client`
|
||||
for the executor and the WebSocket consumer. The Ninja auth class is
|
||||
deliberately absent here — reach for `mizan.jwt.security` for that.
|
||||
"""
|
||||
|
||||
# Token utilities (re-exports from django_jwt_session)
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
@@ -25,7 +15,6 @@ from mizan.jwt.tokens import (
|
||||
JWTUser,
|
||||
)
|
||||
|
||||
# Settings
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator.
|
||||
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
Builds the Edge manifest: a static JSON document mapping each context to its
|
||||
API endpoint, page routes, and parameter names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
@@ -30,20 +22,18 @@ def generate_edge_manifest(
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
Build the manifest dict.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
Each context entry carries the union of its functions' Input field names,
|
||||
its API endpoint under `base_url`, any page routes declared via
|
||||
`@client(route=...)`, and a render strategy derived from whether any
|
||||
parameter is user-scoped. Each mutation entry carries the contexts it
|
||||
affects and the parameter names shared with those contexts.
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
view_urls: Extra page routes per context name, merged with the ones
|
||||
read off `@client(route=...)`.
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
|
||||
@@ -1,153 +1,26 @@
|
||||
"""
|
||||
mizanFormMixin - Turn Django Forms into server functions.
|
||||
|
||||
This mixin transforms any Django Form into mizan server functions,
|
||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||
while exposing them through the unified server function API.
|
||||
|
||||
Usage:
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
)
|
||||
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
message = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
def on_submit_success(self, request):
|
||||
send_email(self.cleaned_data)
|
||||
return {"sent": True}
|
||||
|
||||
Auto-registers server functions:
|
||||
- contact.schema
|
||||
- contact.validate
|
||||
- contact.submit
|
||||
mizanFormMixin exposes a Django Form as the server functions
|
||||
`<name>.schema`, `<name>.validate`, and `<name>.submit`, registered from
|
||||
`__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .schemas import FormValidation
|
||||
|
||||
|
||||
def _django_field_to_python_type(field: forms.Field) -> type:
|
||||
"""
|
||||
Map a Django form field to a Python type for Pydantic schema generation.
|
||||
|
||||
This provides TypeScript with proper field types instead of generic `any`.
|
||||
"""
|
||||
# Handle common Django field types
|
||||
if isinstance(field, forms.BooleanField):
|
||||
return bool
|
||||
elif isinstance(field, forms.IntegerField):
|
||||
return int
|
||||
elif isinstance(field, forms.FloatField):
|
||||
return float
|
||||
elif isinstance(field, forms.DecimalField):
|
||||
return str # Decimals serialize as strings for precision
|
||||
elif isinstance(field, forms.DateTimeField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.DateField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.TimeField):
|
||||
return str # ISO format string
|
||||
elif isinstance(field, forms.JSONField):
|
||||
return dict | list | str | int | float | bool | None
|
||||
elif isinstance(field, forms.MultipleChoiceField):
|
||||
return list[str]
|
||||
elif isinstance(field, forms.FileField):
|
||||
return str # File path/name as string
|
||||
elif isinstance(field, forms.ImageField):
|
||||
return str # File path/name as string
|
||||
else:
|
||||
# Default to string (covers CharField, EmailField, URLField, etc.)
|
||||
return str
|
||||
|
||||
|
||||
def _create_form_input_schema(
|
||||
form_class: type[forms.BaseForm],
|
||||
schema_name: str,
|
||||
) -> type[BaseModel]:
|
||||
"""
|
||||
Create a Pydantic model from Django Form fields.
|
||||
|
||||
This generates a typed schema for the form's input data, giving TypeScript
|
||||
full LSP support (autocomplete, type checking) for form fields.
|
||||
|
||||
Args:
|
||||
form_class: Django Form class to introspect
|
||||
schema_name: Name for the generated Pydantic model (e.g., "ContactFormData")
|
||||
|
||||
Returns:
|
||||
A Pydantic BaseModel subclass with fields matching the form
|
||||
"""
|
||||
# Instantiate form without data to get field definitions
|
||||
try:
|
||||
form = form_class()
|
||||
except TypeError:
|
||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||
fields_dict = getattr(form_class, "base_fields", {})
|
||||
else:
|
||||
fields_dict = form.fields
|
||||
|
||||
# Build Pydantic field definitions
|
||||
pydantic_fields: dict[str, Any] = {}
|
||||
|
||||
for field_name, field in fields_dict.items():
|
||||
python_type = _django_field_to_python_type(field)
|
||||
|
||||
# Optional fields (not required or has initial value)
|
||||
if not field.required:
|
||||
python_type = python_type | None
|
||||
default = None
|
||||
elif field.initial is not None:
|
||||
default = field.initial
|
||||
else:
|
||||
default = ... # Required field
|
||||
|
||||
pydantic_fields[field_name] = (python_type, default)
|
||||
|
||||
# Create the model with a unique name
|
||||
model = create_model(schema_name, **pydantic_fields)
|
||||
|
||||
return model
|
||||
logger = logging.getLogger("mizan.forms")
|
||||
|
||||
|
||||
class mizanFormMeta(BaseModel):
|
||||
"""
|
||||
Configuration for a mizan form.
|
||||
|
||||
This Pydantic model provides type-safe configuration with full LSP support,
|
||||
and serializes to JSON for the frontend schema.
|
||||
|
||||
Required:
|
||||
name: API identifier (e.g., "contact" → contact.schema, contact.validate, contact.submit)
|
||||
|
||||
Display options:
|
||||
title: Display title (default: derived from class name)
|
||||
subtitle: Display subtitle
|
||||
submit_label: Submit button text (default: "Submit")
|
||||
|
||||
Frontend behavior:
|
||||
live_validation: Enable live validation as user types (default: True)
|
||||
live_form_errors: Show form-level errors during live validation (default: False)
|
||||
refetch_schema_on_validate: Refetch schema on each validation - useful for
|
||||
dynamic choice fields (default: False)
|
||||
|
||||
Features:
|
||||
enable_formset: Generate formset endpoints (default: False)
|
||||
Per-form configuration. `name` is the API identifier the three registered
|
||||
function names are built from; the rest are carried into the emitted schema.
|
||||
"""
|
||||
|
||||
# Required
|
||||
@@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel):
|
||||
|
||||
class mizanFormMixin:
|
||||
"""
|
||||
Mixin that exposes a Django Form as mizan server functions.
|
||||
Mix into a Django Form alongside a `mizan = mizanFormMeta(...)` attribute to
|
||||
register `<name>.schema`, `<name>.validate`, and `<name>.submit`.
|
||||
|
||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
)
|
||||
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"sent": True}
|
||||
|
||||
This auto-registers:
|
||||
- contact.schema - Get form field definitions
|
||||
- contact.validate - Validate form data
|
||||
- contact.submit - Submit form
|
||||
|
||||
Overridable methods:
|
||||
get_init_kwargs(cls, request) -> dict: Extra kwargs for form instantiation
|
||||
on_submit_success(self, request) -> dict | None: Handle successful submission
|
||||
on_submit_failure(self, request, errors) -> None: Handle failed submission
|
||||
`get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three
|
||||
override points. Each is called unconditionally, so the definitions here are
|
||||
what a form that overrides none of them does.
|
||||
"""
|
||||
|
||||
# Configuration - subclasses must define this
|
||||
mizan: ClassVar[mizanFormMeta]
|
||||
|
||||
# Track registered forms to avoid duplicate registration
|
||||
# Set on registration so a re-import does not register the class twice
|
||||
_mizan_registered: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||
"""
|
||||
Override to provide extra kwargs for form instantiation.
|
||||
|
||||
Common use: pass request or user to forms that need them.
|
||||
|
||||
Example:
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request):
|
||||
return {"request": request, "user": request.user}
|
||||
Kwargs merged into every instantiation of this form. `request` is passed
|
||||
through to a form whose `__init__` names it; Django's own `BaseForm`
|
||||
signature does not, and rejects any keyword it did not declare, so a
|
||||
form that never asks for the request is constructed on data/files alone.
|
||||
"""
|
||||
accepted = inspect.signature(cls.__init__).parameters
|
||||
if "request" in accepted:
|
||||
return {"request": request}
|
||||
return {}
|
||||
|
||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||
"""
|
||||
Called after successful form validation and submission.
|
||||
|
||||
Override to handle the form submission logic.
|
||||
Return a dict to include data in the response.
|
||||
|
||||
Example:
|
||||
def on_submit_success(self, request):
|
||||
self.save()
|
||||
return {"id": self.instance.pk}
|
||||
Handle a validated submission. A returned dict is carried in the
|
||||
response payload; a ModelForm's `save()` returns a model instance, which
|
||||
is not payload, so only a dict result is forwarded.
|
||||
"""
|
||||
# Default: call save() if available
|
||||
if hasattr(self, "save"):
|
||||
result = self.save()
|
||||
# If save returns something serializable, include it
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return None
|
||||
|
||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||
def on_submit_failure(self, request: HttpRequest, errors: Any) -> None:
|
||||
"""
|
||||
Called after form validation fails.
|
||||
|
||||
Override to add custom error handling, logging, etc.
|
||||
Handle a rejected submission. The per-field errors already travel to the
|
||||
client in the response body, so the rejection is recorded server-side
|
||||
rather than re-raised.
|
||||
"""
|
||||
pass
|
||||
logger.info(
|
||||
"%s rejected a submission on %s: %s",
|
||||
type(self).__name__,
|
||||
getattr(request, "path", "<no path>"),
|
||||
errors,
|
||||
)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Auto-register when a concrete form class is defined."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
# Only register concrete forms with mizan config defined
|
||||
if _is_concrete_mizan_form(cls):
|
||||
_register_form_as_server_functions(cls)
|
||||
|
||||
|
||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
"""
|
||||
Check if a class is a concrete mizan form ready for registration.
|
||||
|
||||
A form is concrete if:
|
||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||
2. It inherits from Django's BaseForm
|
||||
3. It hasn't been registered yet (for this class definition)
|
||||
True when `cls` carries its own mizanFormMeta, is a Django form, and has
|
||||
not already been registered.
|
||||
"""
|
||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||
# Read cls.__dict__ so an inherited config does not re-register.
|
||||
mizan_config = cls.__dict__.get("mizan")
|
||||
if not isinstance(mizan_config, mizanFormMeta):
|
||||
return False
|
||||
|
||||
# Must be a Django form
|
||||
if not issubclass(cls, forms.BaseForm):
|
||||
return False
|
||||
|
||||
# Check if already registered (handle re-imports gracefully)
|
||||
if cls.__dict__.get("_mizan_registered", False):
|
||||
return False
|
||||
|
||||
@@ -280,50 +123,36 @@ def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
|
||||
def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"""
|
||||
Register a Django Form class as mizan server functions.
|
||||
|
||||
Creates and registers:
|
||||
- {name}.schema - Returns form field definitions
|
||||
- {name}.validate - Validates form data
|
||||
- {name}.submit - Validates and submits form
|
||||
|
||||
Each function gets a unique typed schema for better TypeScript LSP support.
|
||||
Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for
|
||||
`form_class`, plus the formset trio when `enable_formset` is set.
|
||||
"""
|
||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import validate_form_instance
|
||||
from mizan.forms.schemas import (
|
||||
FormSchema,
|
||||
FormSubmitFail,
|
||||
FormSubmitPass,
|
||||
FormValidation,
|
||||
)
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.validation_utils import validate_form_instance
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
config: mizanFormMeta = form_class.mizan
|
||||
form_name = config.name
|
||||
|
||||
# Mark as registered
|
||||
form_class._mizan_registered = True
|
||||
|
||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||
# "contact" -> "Contact", "reset_password" -> "ResetPassword"
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||
_form_data_schema_cache: dict[str, type[BaseModel]] = {}
|
||||
|
||||
def get_form_data_schema() -> type[BaseModel]:
|
||||
"""Lazily create the form data schema (form fields aren't available at registration time)."""
|
||||
if "schema" not in _form_data_schema_cache:
|
||||
_form_data_schema_cache["schema"] = _create_form_input_schema(
|
||||
form_class, f"{pascal_name}FormData"
|
||||
)
|
||||
return _form_data_schema_cache["schema"]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Schema Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Schema input wraps the form data for pre-populating dynamic fields
|
||||
# `data` pre-populates dynamic fields before the schema is read off the form.
|
||||
FormSchemaInput = create_model(
|
||||
f"{pascal_name}SchemaInput",
|
||||
data=(dict[str, Any], {}),
|
||||
@@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "schema",
|
||||
"form_class": form_class, # Store reference for schema generation
|
||||
"form_class": form_class,
|
||||
}
|
||||
|
||||
def call(self, input) -> FormSchema:
|
||||
@@ -347,13 +176,12 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
data=input.data if input else {},
|
||||
**init_kwargs,
|
||||
)
|
||||
# Override with mizanFormMeta values
|
||||
# mizanFormMeta wins over anything derived from the form class.
|
||||
if config.title is not None:
|
||||
schema.title = config.title
|
||||
if config.subtitle is not None:
|
||||
schema.subtitle = config.subtitle
|
||||
schema.submit_label = config.submit_label
|
||||
# Behavior settings are nested in schema.meta
|
||||
schema.meta.live_validation = config.live_validation
|
||||
schema.meta.live_form_errors = config.live_form_errors
|
||||
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate
|
||||
@@ -367,7 +195,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
# Validate Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Use generic dict input - form fields aren't available during __init_subclass__
|
||||
# Generic dict input — form fields are unavailable during __init_subclass__.
|
||||
FormValidateInput = create_model(
|
||||
f"{pascal_name}ValidateInput",
|
||||
data=(dict[str, Any], ...),
|
||||
@@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
|
||||
def call(self, input) -> FormValidation:
|
||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||
# Input data is already a dict
|
||||
data = input.data
|
||||
_, validation = validate_form_instance(
|
||||
form_class,
|
||||
@@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
class SubmitFunction(ServerFunction):
|
||||
"""
|
||||
Submit function handles both JSON and multipart/form-data.
|
||||
|
||||
The executor detects form functions and parses the request appropriately.
|
||||
"""
|
||||
|
||||
# Use dict for input - form fields unknown at registration time
|
||||
Input = None # Signals executor to pass raw dict
|
||||
# Input=None signals the executor to pass the raw dict through, since
|
||||
# the Django form owns validation.
|
||||
Input = None
|
||||
|
||||
_meta: ClassVar[dict] = {
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "submit",
|
||||
"multipart": True, # Signal that this function accepts multipart
|
||||
"multipart": True,
|
||||
}
|
||||
|
||||
def call(self, input) -> FormSubmitPass | FormSubmitFail:
|
||||
"""Execute form submission."""
|
||||
request = self.request
|
||||
|
||||
# Check if we have multipart data from executor
|
||||
# Multipart bodies are parsed onto the request before dispatch.
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input is not None:
|
||||
# JSON input - already a dict
|
||||
data = input if isinstance(input, dict) else input.model_dump()
|
||||
files = None
|
||||
else:
|
||||
@@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
|
||||
init_kwargs = form_class.get_init_kwargs(request)
|
||||
|
||||
# Create and validate form
|
||||
form, validation = validate_form_instance(
|
||||
form_class,
|
||||
data=data,
|
||||
@@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
# Call the form's on_submit_success
|
||||
result_data = form.on_submit_success(request)
|
||||
return FormSubmitPass(success=True, data=result_data)
|
||||
|
||||
# Call the form's on_submit_failure
|
||||
form.on_submit_failure(request, validation)
|
||||
return FormSubmitFail(success=False, errors=validation)
|
||||
|
||||
@@ -472,36 +289,34 @@ def _register_formset_functions(
|
||||
form_class: type,
|
||||
form_name: str,
|
||||
) -> None:
|
||||
"""Register formset server functions for a form."""
|
||||
"""Register the `{name}.formset.*` server functions for a form."""
|
||||
from django.forms import formset_factory
|
||||
|
||||
from .schemas import (
|
||||
from mizan.forms.schemas import (
|
||||
FormsetSchema,
|
||||
FormsetSubmitFail,
|
||||
FormsetSubmitPass,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import build_formset_validation
|
||||
from .formset_utils import forms_to_formset_post_data
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.validation_utils import build_formset_validation
|
||||
from mizan.forms.formset_utils import forms_to_formset_post_data
|
||||
from mizan_core.registry import register
|
||||
from mizan_core.client.function import ServerFunction
|
||||
|
||||
formset_class = formset_factory(form_class)
|
||||
|
||||
# Generate PascalCase name for schemas
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Formset Schema Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict inputs throughout — form fields are unavailable during
|
||||
# __init_subclass__, so no typed schema can be built here.
|
||||
FormsetSchemaInput = create_model(
|
||||
f"{pascal_name}FormsetSchemaInput",
|
||||
forms=(list[dict[str, Any]], []),
|
||||
@@ -542,7 +357,6 @@ def _register_formset_functions(
|
||||
# Formset Validate Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict input - form fields aren't available during __init_subclass__
|
||||
FormsetValidateInput = create_model(
|
||||
f"{pascal_name}FormsetValidateInput",
|
||||
forms=(list[dict[str, Any]], ...),
|
||||
@@ -560,12 +374,12 @@ def _register_formset_functions(
|
||||
|
||||
def call(self, input) -> FormsetValidation:
|
||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
|
||||
formset_data = forms_to_formset_post_data(forms_data)
|
||||
formset = formset_class(formset_data, form_kwargs=init_kwargs)
|
||||
|
||||
# Every submitted row must validate; blank rows are not excused.
|
||||
for form in formset:
|
||||
form.empty_permitted = False
|
||||
|
||||
@@ -578,7 +392,6 @@ def _register_formset_functions(
|
||||
# Formset Submit Function
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generic dict input - form fields aren't available during __init_subclass__
|
||||
FormsetSubmitInput = create_model(
|
||||
f"{pascal_name}FormsetSubmitInput",
|
||||
forms=(list[dict[str, Any]], ...),
|
||||
@@ -598,12 +411,10 @@ def _register_formset_functions(
|
||||
request = self.request
|
||||
init_kwargs = form_class.get_init_kwargs(request)
|
||||
|
||||
# Handle multipart vs JSON
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
post_data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input and hasattr(input, "forms"):
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
post_data = forms_to_formset_post_data(forms_data)
|
||||
files = None
|
||||
@@ -620,10 +431,8 @@ def _register_formset_functions(
|
||||
return FormsetSubmitPass(success=True)
|
||||
|
||||
validation = build_formset_validation(formset)
|
||||
# Call failure handler on each form
|
||||
for form in formset.forms:
|
||||
if hasattr(form, "on_submit_failure"):
|
||||
form.on_submit_failure(request, validation)
|
||||
form.on_submit_failure(request, validation)
|
||||
|
||||
return FormsetSubmitFail(success=False, errors=validation)
|
||||
|
||||
@@ -641,10 +450,8 @@ def register_form(
|
||||
submit_handler: Any = None,
|
||||
) -> None:
|
||||
"""
|
||||
Register a Django Form class as Mizan server functions.
|
||||
|
||||
Creates and registers `{name}.schema`, `{name}.validate`, and
|
||||
`{name}.submit` (if a submit_handler is provided).
|
||||
Register a plain Django Form as `{name}.schema`, `{name}.validate`, and —
|
||||
when `submit_handler` is given — `{name}.submit`.
|
||||
"""
|
||||
from mizan_core.client.function import create_form_functions
|
||||
from mizan_core.registry import register
|
||||
@@ -660,10 +467,8 @@ def register_form(
|
||||
|
||||
def get_forms() -> dict[str, list]:
|
||||
"""
|
||||
Group registered form-related functions by their form name.
|
||||
|
||||
Returns a mapping like:
|
||||
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
|
||||
Group registered form-related functions by their form name, e.g.
|
||||
`{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`.
|
||||
"""
|
||||
from mizan_core.registry import get_all_functions
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
from django import forms
|
||||
from django.forms import Field
|
||||
|
||||
from .schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||
from mizan.forms.schemas import FieldChoice, FieldSchema, FormMeta, FormSchema
|
||||
|
||||
|
||||
def create_form_instance(
|
||||
@@ -14,65 +14,58 @@ def create_form_instance(
|
||||
**kwargs,
|
||||
) -> forms.BaseForm:
|
||||
"""
|
||||
Create a form instance, gracefully handling kwargs that the form doesn't accept.
|
||||
|
||||
Some Django forms (like allauth's) accept `request` in __init__, others don't.
|
||||
This function tries with all kwargs first, then progressively removes kwargs
|
||||
that cause TypeErrors until instantiation succeeds.
|
||||
Instantiate `form_class`, dropping kwargs its __init__ rejects.
|
||||
|
||||
Django form __init__ signatures vary — some accept `request`, others do
|
||||
not — so instantiation is retried with the offending kwarg removed until
|
||||
it succeeds or the TypeError is not about an unexpected keyword.
|
||||
"""
|
||||
# Common kwargs that forms may or may not accept
|
||||
optional_kwargs = ['request', 'user', 'instance']
|
||||
|
||||
# Build init kwargs
|
||||
|
||||
init_kwargs = dict(kwargs)
|
||||
if data is not None:
|
||||
init_kwargs['data'] = data
|
||||
if files is not None:
|
||||
init_kwargs['files'] = files
|
||||
|
||||
|
||||
while True:
|
||||
try:
|
||||
return form_class(**init_kwargs)
|
||||
except TypeError as e:
|
||||
error_msg = str(e)
|
||||
|
||||
# Check if it's an unexpected keyword argument error
|
||||
|
||||
if "unexpected keyword argument" not in error_msg:
|
||||
raise
|
||||
|
||||
# Find which kwarg caused the problem and remove it
|
||||
|
||||
removed = False
|
||||
for kwarg in optional_kwargs:
|
||||
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
|
||||
init_kwargs.pop(kwarg)
|
||||
removed = True
|
||||
break
|
||||
|
||||
# If we couldn't identify/remove the problematic kwarg, re-raise
|
||||
|
||||
if not removed:
|
||||
raise
|
||||
|
||||
|
||||
def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
|
||||
"""
|
||||
Extract choices from a field, handling ModelChoiceField properly.
|
||||
ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable.
|
||||
Extract a field's choices as JSON-serializable pairs. ModelChoiceField
|
||||
yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`.
|
||||
"""
|
||||
if not hasattr(field, "choices"):
|
||||
return None
|
||||
|
||||
choices: list[FieldChoice] = []
|
||||
for raw_value, label in field.choices:
|
||||
value = getattr(
|
||||
raw_value, "value", raw_value
|
||||
) # ModelChoiceIteratorValue -> .value
|
||||
value = getattr(raw_value, "value", raw_value)
|
||||
choices.append(FieldChoice(value=str(value), label=str(label)))
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def _get_initial(value: Any) -> Any:
|
||||
"""Convert initial value to JSON-serializable format."""
|
||||
"""Convert an initial value to a JSON-serializable form."""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
@@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any:
|
||||
|
||||
|
||||
def _class_name_to_title(name: str) -> str:
|
||||
"""
|
||||
Convert a class name to a human-readable title.
|
||||
e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'
|
||||
"""
|
||||
# Remove 'Form' suffix
|
||||
"""'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'."""
|
||||
name = re.sub(r"Form$", "", name)
|
||||
# Insert spaces before capital letters
|
||||
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||
return name
|
||||
|
||||
|
||||
def _class_name_to_slug(name: str) -> str:
|
||||
"""
|
||||
Convert a class name to a slug.
|
||||
e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'
|
||||
"""
|
||||
# Remove 'Form' suffix
|
||||
"""'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'."""
|
||||
name = re.sub(r"Form$", "", name)
|
||||
# Insert underscores before capital letters and lowercase
|
||||
name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
|
||||
return name.lower()
|
||||
|
||||
@@ -114,48 +97,31 @@ def build_form_schema(
|
||||
**kwargs,
|
||||
) -> FormSchema:
|
||||
"""
|
||||
Produce a FormSchema for the given Django form class and (optional) data.
|
||||
Produce a FormSchema for a Django form class and optional bound data.
|
||||
|
||||
The form class can define metadata via an inner Meta class:
|
||||
|
||||
class MyForm(forms.Form):
|
||||
class Meta:
|
||||
form_name = "my_form"
|
||||
title = "My Form Title"
|
||||
subtitle = "Optional description"
|
||||
submit_label = "Submit"
|
||||
|
||||
# Frontend behavior (optional)
|
||||
refetch_schema_on_validate = False # Set True for dynamic choice fields
|
||||
live_validation = True # Set False to disable live validation
|
||||
live_form_errors = False # Set True to show form errors live
|
||||
|
||||
If not provided, sensible defaults are derived from the class name.
|
||||
Attributes on the form's inner `Meta` class — `form_name`, `title`,
|
||||
`subtitle`, `submit_label`, `refetch_schema_on_validate`,
|
||||
`live_validation`, `live_form_errors` — override the values otherwise
|
||||
derived from the class name.
|
||||
"""
|
||||
form = create_form_instance(form_class, data=data, **kwargs)
|
||||
|
||||
# Extract metadata from form's Meta class
|
||||
form_meta = getattr(form_class, "Meta", None)
|
||||
|
||||
# Get form name (used as identifier)
|
||||
name = getattr(form_meta, "form_name", None)
|
||||
if name is None:
|
||||
name = _class_name_to_slug(form_class.__name__)
|
||||
|
||||
# Get title (human-readable heading)
|
||||
title = getattr(form_meta, "title", None)
|
||||
if title is None:
|
||||
title = _class_name_to_title(form_class.__name__)
|
||||
|
||||
# Get optional subtitle
|
||||
subtitle = getattr(form_meta, "subtitle", None)
|
||||
|
||||
# Get submit button label
|
||||
submit_label = getattr(form_meta, "submit_label", None)
|
||||
if submit_label is None:
|
||||
submit_label = "Submit"
|
||||
|
||||
# Build frontend behavior metadata
|
||||
frontend_meta = FormMeta(
|
||||
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
|
||||
live_validation=getattr(form_meta, "live_validation", True),
|
||||
|
||||
@@ -4,13 +4,13 @@ from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils.datastructures import MultiValueDict
|
||||
|
||||
from .schemas import (
|
||||
from mizan.forms.schemas import (
|
||||
FieldError,
|
||||
FieldErrorList,
|
||||
FormValidation,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import create_form_instance
|
||||
from mizan.forms.schema_utils import create_form_instance
|
||||
|
||||
|
||||
def validate_form_instance(
|
||||
@@ -19,12 +19,9 @@ def validate_form_instance(
|
||||
files: MultiValueDict[str, UploadedFile] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[forms.BaseForm, FormValidation]:
|
||||
"""
|
||||
Build a form instance and return (form, structured_validation_errors).
|
||||
"""
|
||||
"""Build a form instance and return it alongside its structured field errors."""
|
||||
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
|
||||
|
||||
# Run validation
|
||||
form.is_valid()
|
||||
|
||||
validation = FormValidation(
|
||||
@@ -46,9 +43,7 @@ def validate_form_instance(
|
||||
|
||||
|
||||
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
|
||||
"""
|
||||
Turn a Django formset into a FormsetValidation structure.
|
||||
"""
|
||||
"""Turn a Django formset's non-form and per-form errors into a FormsetValidation."""
|
||||
return FormsetValidation(
|
||||
general=[str(e) if e else "" for e in formset.non_form_errors()],
|
||||
per_form=[
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
"""
|
||||
mizan.jwt - JWT authentication for server functions.
|
||||
JWT issuance and validation for mizan server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
- JWT authentication utilities for validating tokens
|
||||
|
||||
Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Usage in apps.py or urls.py (to register the functions):
|
||||
import mizan.jwt.functions # noqa: F401
|
||||
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
`jwt_obtain` / `jwt_refresh` are server functions; importing
|
||||
`mizan.jwt.functions` is what registers them. The Ninja auth class
|
||||
`JWTAuth` / `jwt_auth` resolves through `__getattr__` so that importing this
|
||||
package does not pull django-ninja's settings access in at module load time.
|
||||
"""
|
||||
|
||||
# Server functions (import to register with @client decorator)
|
||||
from .functions import jwt_obtain, jwt_refresh
|
||||
from mizan.jwt.functions import jwt_obtain, jwt_refresh
|
||||
|
||||
# Token utilities
|
||||
from .tokens import (
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -31,17 +20,12 @@ from .tokens import (
|
||||
JWTUser,
|
||||
)
|
||||
|
||||
# Settings
|
||||
from .settings import get_settings, JWTSettings
|
||||
|
||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||
# django-ninja's settings access at module load time.
|
||||
# Use: from mizan.jwt.security import jwt_auth
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("JWTAuth", "jwt_auth"):
|
||||
from .security import JWTAuth, jwt_auth
|
||||
from mizan.jwt.security import JWTAuth, jwt_auth
|
||||
|
||||
globals()["JWTAuth"] = JWTAuth
|
||||
globals()["jwt_auth"] = jwt_auth
|
||||
|
||||
@@ -1,64 +1,33 @@
|
||||
"""
|
||||
Django Ninja Security Classes for JWT Authentication
|
||||
|
||||
Provides authentication classes that can be used with Django Ninja's
|
||||
auth parameter to protect API endpoints.
|
||||
Django Ninja security class for JWT bearer authentication, usable as
|
||||
`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list.
|
||||
"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from ninja.security import HttpBearer
|
||||
|
||||
from .tokens import decode_token, JWTUser
|
||||
from mizan.jwt.tokens import decode_token, JWTUser
|
||||
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
"""
|
||||
JWT Bearer token authentication for Django Ninja.
|
||||
|
||||
Usage:
|
||||
from ninja_jwt_session import jwt_auth
|
||||
|
||||
@api.get("/protected/", auth=jwt_auth)
|
||||
def protected_endpoint(request):
|
||||
return {"user_id": request.user.id}
|
||||
|
||||
Or globally:
|
||||
api = NinjaExtraAPI(auth=[django_auth, jwt_auth])
|
||||
|
||||
The token must be passed in the Authorization header:
|
||||
Authorization: Bearer <access_token>
|
||||
|
||||
IMPORTANT: This is stateless - no database query is made.
|
||||
request.user is a JWTUser object with id, is_staff, is_superuser.
|
||||
If you need the full User object, query it explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
Reads `Authorization: Bearer <access_token>` and sets `request.user` to a
|
||||
JWTUser built from the token claims. No database query is made, so the
|
||||
resulting user carries only id, is_staff, and is_superuser.
|
||||
"""
|
||||
|
||||
def authenticate(self, request: HttpRequest, token: str):
|
||||
"""
|
||||
Validate the JWT and return a JWTUser if valid.
|
||||
|
||||
Returns None (authentication failed) if:
|
||||
- Token is invalid or expired
|
||||
- Token is not an access token
|
||||
|
||||
Note: No database query is made. The JWTUser is created from
|
||||
token claims. This is truly stateless authentication.
|
||||
"""
|
||||
# Decode and validate the token
|
||||
"""Return a JWTUser for a valid access token, or None to fail auth."""
|
||||
payload = decode_token(token, expected_type="access")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
jwt_user = JWTUser(payload)
|
||||
|
||||
# Set request.user for compatibility with code expecting it
|
||||
request.user = jwt_user
|
||||
|
||||
return jwt_user
|
||||
|
||||
|
||||
# Singleton instance for convenience
|
||||
jwt_auth = JWTAuth()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""
|
||||
JWT Token Creation and Validation
|
||||
JWT creation and validation over PyJWT.
|
||||
|
||||
Uses PyJWT directly - no allauth dependency.
|
||||
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||
Every token carries the Django session key in `sid`; `validate_session`
|
||||
re-checks that the session still exists, which is what makes logout revoke
|
||||
outstanding tokens immediately.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
import jwt
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
from .settings import get_settings
|
||||
from mizan.jwt.settings import get_settings
|
||||
|
||||
logger = logging.getLogger("mizan.jwt")
|
||||
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
@@ -34,13 +37,9 @@ class TokenPayload(NamedTuple):
|
||||
|
||||
class JWTUser:
|
||||
"""
|
||||
Minimal user object created from JWT claims.
|
||||
|
||||
Used as request.user for JWT-authenticated requests.
|
||||
No database query required - all data comes from the token.
|
||||
|
||||
If you need the full User object with all fields, query explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
Stand-in for `request.user` built entirely from JWT claims — no database
|
||||
row is loaded, so only id, is_staff, and is_superuser are real. Anything
|
||||
else about the user has to be queried explicitly by the caller.
|
||||
"""
|
||||
|
||||
def __init__(self, payload: TokenPayload):
|
||||
@@ -50,7 +49,7 @@ class JWTUser:
|
||||
self.is_superuser = payload.is_superuser
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True # Assumed active if they have a valid token
|
||||
self.is_active = True # A valid unexpired token stands in for the flag
|
||||
|
||||
def __str__(self):
|
||||
return f"JWTUser(id={self.id})"
|
||||
@@ -66,18 +65,7 @@ def create_access_token(
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "access"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
"""Create a short-lived access token."""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
@@ -105,18 +93,7 @@ def create_refresh_token(
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a longer-lived refresh token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "refresh"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
"""Create a longer-lived refresh token."""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
@@ -157,13 +134,10 @@ def create_token_pair(
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Returns None if:
|
||||
- Token is invalid or expired
|
||||
- Token type doesn't match expected_type (if specified)
|
||||
Decode and validate a JWT, returning None when it is malformed, expired,
|
||||
or not of `expected_type`.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -173,11 +147,18 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
settings.public_key,
|
||||
algorithms=[settings.algorithm],
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
except jwt.PyJWTError as exc:
|
||||
# Expired and forged tokens are routine on a public endpoint, so this
|
||||
# stays at debug rather than flooding the log on every bad request.
|
||||
logger.debug("JWT rejected: %s", exc)
|
||||
return None
|
||||
|
||||
# Validate token type if specified
|
||||
if expected_type and payload.get("type") != expected_type:
|
||||
logger.debug(
|
||||
"JWT rejected: expected type %r, got %r",
|
||||
expected_type,
|
||||
payload.get("type"),
|
||||
)
|
||||
return None
|
||||
|
||||
return TokenPayload(
|
||||
@@ -193,10 +174,8 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
|
||||
def validate_session(session_key: str) -> bool:
|
||||
"""
|
||||
Check if a session is still valid (exists and not expired).
|
||||
|
||||
This is the key to immediate logout revocation - if the session
|
||||
is destroyed, tokens tied to it become invalid.
|
||||
Report whether the Django session backing a token still exists. Returns
|
||||
True unconditionally when session validation is switched off in settings.
|
||||
"""
|
||||
from importlib import import_module
|
||||
|
||||
@@ -207,36 +186,30 @@ def validate_session(session_key: str) -> bool:
|
||||
if not jwt_settings.validate_session:
|
||||
return True
|
||||
|
||||
# Use the configured session engine
|
||||
engine = import_module(django_settings.SESSION_ENGINE)
|
||||
SessionStore = engine.SessionStore
|
||||
|
||||
# Try to load the session
|
||||
session = SessionStore(session_key=session_key)
|
||||
|
||||
# Check if session exists and is not empty
|
||||
# exists() is more reliable than checking load() result
|
||||
# exists() reads the backend directly; load() would silently hand back an
|
||||
# empty session for a missing key.
|
||||
return session.exists(session_key)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||
"""
|
||||
Use a refresh token to obtain new tokens.
|
||||
|
||||
Returns None if:
|
||||
- Refresh token is invalid or expired
|
||||
- Associated session no longer exists
|
||||
Exchange a refresh token for a fresh pair carrying the same claims.
|
||||
Returns None when the token is invalid or its session is gone.
|
||||
"""
|
||||
payload = decode_token(refresh_token, expected_type="refresh")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Validate the session still exists
|
||||
if not validate_session(payload.session_key):
|
||||
logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key)
|
||||
return None
|
||||
|
||||
# Issue new token pair with same claims
|
||||
return create_token_pair(
|
||||
payload.user_id,
|
||||
payload.session_key,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
"""
|
||||
Export channels schema as OpenAPI JSON for TypeScript generation.
|
||||
|
||||
Uses Django Ninja's schema generation for robust Pydantic→OpenAPI conversion.
|
||||
The schema is consumed by openapi-typescript for type generation.
|
||||
|
||||
Usage:
|
||||
python manage.py export_channels_schema
|
||||
Writes the channels schema to stdout as OpenAPI JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -29,6 +23,7 @@ class Command(BaseCommand):
|
||||
|
||||
schema = get_channels_openapi_schema()
|
||||
|
||||
# indent=0 is not compact in json.dumps; None is.
|
||||
indent = options["indent"] if options["indent"] > 0 else None
|
||||
output = json.dumps(schema, indent=indent)
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""
|
||||
Export Edge Manifest
|
||||
|
||||
Generates the static JSON manifest that Mizan Edge reads at deploy time
|
||||
to configure CDN cache rules and invalidation routing.
|
||||
|
||||
Usage:
|
||||
python manage.py export_edge_manifest
|
||||
python manage.py export_edge_manifest --output mizan-manifest.json
|
||||
python manage.py export_edge_manifest --base-url /api/mizan
|
||||
"""
|
||||
"""Management command emitting the edge cache manifest as JSON."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"""
|
||||
Mizan IR (KDL) export — Django management command.
|
||||
|
||||
Usage:
|
||||
python manage.py export_mizan_ir
|
||||
|
||||
Triggers Mizan client discovery to populate the registry, then writes
|
||||
the canonical Mizan IR as KDL to stdout. The Rust codegen binary
|
||||
consumes this directly.
|
||||
Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary
|
||||
consumes. Nothing else in this command may write to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
|
||||
@@ -20,9 +15,6 @@ class Command(BaseCommand):
|
||||
help = "Export every registered @client function as Mizan IR (KDL)."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
# Load every project-side @client function so the registry is
|
||||
# populated before we emit. Conventionally apps/*/clients.py.
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
|
||||
mizan_clients("apps")
|
||||
# Discovery populates the registry build_ir() reads.
|
||||
mizan_clients()
|
||||
self.stdout.write(build_ir(), ending="")
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""
|
||||
mizan.setup - Django integration helpers.
|
||||
|
||||
The function/composition registry now lives in `mizan_core.registry`.
|
||||
Channels register themselves through the channel-specific registry in
|
||||
`mizan.channels`. Forms register through `mizan.forms`. This module
|
||||
re-exports the helpers that Django mizan users typically reach for, so
|
||||
`from mizan.setup import register, get_function, mizan_clients, …` keeps
|
||||
working as a single curated surface.
|
||||
Curated Django-side surface: registration, lookup, discovery, and settings
|
||||
helpers, re-exported from `mizan_core.registry`, `mizan.channels`,
|
||||
`mizan.forms`, and this package's own modules.
|
||||
"""
|
||||
|
||||
from mizan_core.registry import (
|
||||
@@ -35,12 +30,13 @@ from mizan.forms import (
|
||||
get_forms,
|
||||
)
|
||||
|
||||
from .discovery import (
|
||||
from mizan.setup.discovery import (
|
||||
discover_apps_roots,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
)
|
||||
|
||||
from .settings import (
|
||||
from mizan.setup.settings import (
|
||||
mizanSettings,
|
||||
get_settings,
|
||||
clear_settings_cache,
|
||||
@@ -67,6 +63,7 @@ __all__ = [
|
||||
"validate_registry",
|
||||
"clear_registry",
|
||||
# Discovery
|
||||
"discover_apps_roots",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
# Settings
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
from django.apps import apps as django_apps
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from mizan._vendor.app_visitor import (
|
||||
DjangoAppVisitor,
|
||||
DjangoAppVisitorHandler,
|
||||
get_members,
|
||||
)
|
||||
|
||||
from mizan_core.registry import register, get_function
|
||||
from mizan_core.client.function import ServerFunction
|
||||
@@ -9,22 +17,20 @@ from mizan_core.client.function import ServerFunction
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _RegisterServerFunctions:
|
||||
class _RegisterServerFunctions(DjangoAppVisitorHandler):
|
||||
def on_module(
|
||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||
) -> None:
|
||||
for name, member in members:
|
||||
# Register ServerFunction subclasses
|
||||
if (
|
||||
isinstance(member, type)
|
||||
and issubclass(member, ServerFunction)
|
||||
and member is not ServerFunction
|
||||
and hasattr(member, "__name__")
|
||||
):
|
||||
# Use the function name as registration name
|
||||
fn_name = getattr(member, "name", None) or member.__name__
|
||||
|
||||
# Skip already registered (idempotent)
|
||||
# Idempotent: the same class under the same name is a re-visit.
|
||||
if get_function(fn_name) is member:
|
||||
continue
|
||||
|
||||
@@ -40,14 +46,48 @@ class _RegisterServerFunctions:
|
||||
)
|
||||
|
||||
|
||||
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root
|
||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||
visitor.visit(_RegisterServerFunctions())
|
||||
def discover_apps_roots() -> list[str]:
|
||||
"""
|
||||
Dotted package prefixes the project's own apps sit under, relative to
|
||||
BASE_DIR. `MIZAN_APPS_ROOT` in Django settings pins the answer.
|
||||
|
||||
An app counts as the project's own only when its directory is exactly
|
||||
BASE_DIR joined with its dotted name — the same resolution DjangoAppVisitor
|
||||
performs. That excludes installed packages even when the virtualenv holding
|
||||
them sits inside BASE_DIR.
|
||||
|
||||
An app declared as "apps.blog" yields "apps"; a top-level "blog" yields "".
|
||||
"""
|
||||
pinned = getattr(django_settings, "MIZAN_APPS_ROOT", None)
|
||||
if pinned is not None:
|
||||
return [pinned]
|
||||
|
||||
base_dir = Path(django_settings.BASE_DIR).resolve()
|
||||
|
||||
roots: list[str] = []
|
||||
for app_config in django_apps.get_app_configs():
|
||||
expected = base_dir.joinpath(*app_config.name.split("."))
|
||||
if Path(app_config.path).resolve() != expected:
|
||||
continue
|
||||
root = app_config.name.rpartition(".")[0]
|
||||
if root not in roots:
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def mizan_clients(apps_root: str | None = None, layer: str = "clients") -> None:
|
||||
"""
|
||||
Scan <app>/<layer>.py and <app>/<layer>/**/*.py and register every
|
||||
ServerFunction found. `apps_root` of None scans every discovered root.
|
||||
"""
|
||||
handler = _RegisterServerFunctions()
|
||||
roots = [apps_root] if apps_root is not None else discover_apps_roots()
|
||||
for root in roots:
|
||||
DjangoAppVisitor(layer=layer, apps_root=root).visit(handler)
|
||||
|
||||
|
||||
# Registers server functions from one module path, e.g. 'mizan.jwt.functions'
|
||||
def mizan_module(module_path: str) -> None:
|
||||
"""Register the server functions defined in one module, e.g. 'mizan.jwt.functions'."""
|
||||
members = get_members(module_path)
|
||||
handler = _RegisterServerFunctions()
|
||||
handler.on_module("", [], members)
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
"""
|
||||
mizan.ssr — Server-side rendering via Bun subprocess.
|
||||
|
||||
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'OPTIONS': {
|
||||
'worker_path': 'frontend/ssr-worker.tsx',
|
||||
'timeout': 5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then use Django's standard render():
|
||||
|
||||
return render(request, 'ProfilePage', {'user_id': 5})
|
||||
|
||||
The component name is the template name. The context dict becomes props.
|
||||
Server-side rendering as a Django template backend: the template name is the
|
||||
React component's file path and the context dict becomes its props.
|
||||
"""
|
||||
|
||||
from .backend import MizanTemplates
|
||||
from mizan.ssr.backend import MizanTemplates
|
||||
|
||||
__all__ = ["MizanTemplates"]
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
"""
|
||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'DIRS': [BASE_DIR / 'frontend'],
|
||||
'OPTIONS': {
|
||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
||||
Django template backend that resolves a template name to a .tsx/.jsx file
|
||||
under DIRS and renders it through a Bun subprocess. `OPTIONS['worker']` names
|
||||
the worker script; `OPTIONS['timeout']` bounds a single render.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .bridge import SSRBridge
|
||||
from mizan.ssr.bridge import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
@@ -38,12 +28,12 @@ class MizanTemplate:
|
||||
import json as _json
|
||||
|
||||
props = dict(context) if context else {}
|
||||
# Neither is JSON-serializable, and neither belongs in client hydration.
|
||||
props.pop("request", None)
|
||||
props.pop("csrf_token", None)
|
||||
|
||||
result = self._bridge.render(self.file_path, props)
|
||||
|
||||
# Serialize props as hydration data for client-side React
|
||||
hydration_json = _json.dumps(props, sort_keys=True, default=str)
|
||||
|
||||
return mark_safe(
|
||||
@@ -54,10 +44,12 @@ class MizanTemplate:
|
||||
|
||||
class MizanTemplates(BaseEngine):
|
||||
"""
|
||||
Django template backend that renders React components via Bun.
|
||||
Template backend whose template names are file paths resolved against
|
||||
DIRS. The bridge subprocess is created on first template lookup.
|
||||
|
||||
Template names are file paths resolved against DIRS.
|
||||
Same model as Django's built-in template engines.
|
||||
A template is a module the Bun worker imports by path, so a source string
|
||||
names nothing this engine can render — `from_string` is left to BaseEngine,
|
||||
which rejects it.
|
||||
"""
|
||||
|
||||
def __init__(self, params: dict[str, Any]) -> None:
|
||||
@@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine):
|
||||
self.get_bridge(),
|
||||
)
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
def from_string(self, template_code: str) -> MizanTemplate:
|
||||
raise TemplateDoesNotExist(
|
||||
"MizanTemplates renders .tsx files, not template strings."
|
||||
)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
Persistent Bun subprocess speaking newline-delimited JSON-RPC over
|
||||
stdin/stdout.
|
||||
|
||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||
Response: {"id": 1, "html": "<div>...</div>"}
|
||||
|
||||
The subprocess stays alive across requests. It is started on first use
|
||||
and restarted automatically if it crashes.
|
||||
Message id 0 is reserved for the worker's unsolicited ready signal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,10 +29,9 @@ class RenderResult:
|
||||
|
||||
class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
Owns the Bun subprocess. Thread-safe: concurrent render() callers are
|
||||
matched to their response by message id, and stdin writes are serialized
|
||||
so requests never interleave mid-line.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||
@@ -42,18 +39,17 @@ class SSRBridge:
|
||||
self._timeout = timeout
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||
self._write_lock = threading.Lock()
|
||||
self._counter = 0
|
||||
self._pending: dict[int, threading.Event] = {}
|
||||
self._results: dict[int, dict] = {}
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure cleanup on process exit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
"""Start the Bun subprocess if it's not running."""
|
||||
"""Start the Bun subprocess if it is not already running."""
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
|
||||
@@ -73,7 +69,6 @@ class SSRBridge:
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Wait for the "ready" signal from the worker
|
||||
if not self._ready.wait(timeout=self._timeout):
|
||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||
self.shutdown()
|
||||
@@ -82,7 +77,7 @@ class SSRBridge:
|
||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||
|
||||
def _read_responses(self) -> None:
|
||||
"""Background thread that reads JSON responses from stdout."""
|
||||
"""Background thread that reads JSON responses from the worker's stdout."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if isinstance(line, bytes):
|
||||
@@ -99,7 +94,6 @@ class SSRBridge:
|
||||
|
||||
msg_id = msg.get("id")
|
||||
|
||||
# Ready signal (id=0)
|
||||
if msg_id == 0 and msg.get("ready"):
|
||||
self._ready.set()
|
||||
continue
|
||||
@@ -112,18 +106,10 @@ class SSRBridge:
|
||||
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
Render the component at absolute path `file` with `props` to HTML.
|
||||
|
||||
Args:
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
RenderResult with the HTML string.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails.
|
||||
Raises TimeoutError past the configured timeout and RuntimeError when
|
||||
the worker reports a render error or its pipe is broken.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
@@ -139,7 +125,6 @@ class SSRBridge:
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||
with self._write_lock:
|
||||
try:
|
||||
self._proc.stdin.write(request.encode("utf-8"))
|
||||
@@ -163,19 +148,24 @@ class SSRBridge:
|
||||
return RenderResult(html=result["html"])
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the Bun subprocess."""
|
||||
if self._proc is not None:
|
||||
"""Stop the Bun subprocess, escalating to kill if terminate does not land."""
|
||||
if self._proc is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except OSError:
|
||||
logger.warning("Closing SSR worker stdin failed", exc_info=True)
|
||||
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.warning("SSR worker did not terminate; killing it", exc_info=True)
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
self._proc.kill()
|
||||
except OSError:
|
||||
logger.warning("Killing SSR worker failed", exc_info=True)
|
||||
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Hello({ name }: { name?: string }) {
|
||||
return <div data-mizan-component="Hello">Hello, {name}!</div>
|
||||
}
|
||||
@@ -1,29 +1,21 @@
|
||||
"""
|
||||
Protocol Benchmark: HTTP vs WebSocket Server Functions
|
||||
Latency and throughput measurements for server-function calls, comparing the
|
||||
direct executor path against the full HTTP view path.
|
||||
|
||||
Compares performance of HTTP POST vs WebSocket RPC for server function calls.
|
||||
Includes realistic scenarios with ORM queries.
|
||||
|
||||
Usage:
|
||||
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
||||
|
||||
Note:
|
||||
These are not unit tests - they measure performance. Results are printed
|
||||
to stdout and should be run in isolation for accurate measurements.
|
||||
These measure rather than assert on timing; each one still checks that the
|
||||
function under measurement returned the right answer. Timings printed here are
|
||||
only meaningful when the module is run in isolation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import connection
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
||||
from django.test import RequestFactory, TransactionTestCase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||
@@ -141,9 +133,10 @@ def setup_benchmark_functions():
|
||||
|
||||
class ProtocolBenchmark(TransactionTestCase):
|
||||
"""
|
||||
Benchmark comparing HTTP vs WebSocket (simulated) performance.
|
||||
Per-call latency for the executor path versus the HTTP view path.
|
||||
|
||||
Uses TransactionTestCase to ensure database state is realistic.
|
||||
TransactionTestCase rather than TestCase: the timings must include real
|
||||
commits instead of running inside one rolled-back transaction.
|
||||
"""
|
||||
|
||||
# Number of iterations for each benchmark
|
||||
@@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
# Create test users for ORM benchmarks
|
||||
self._create_test_users()
|
||||
|
||||
def _create_test_users(self):
|
||||
"""Create test users for benchmarks."""
|
||||
# Create 100 test users
|
||||
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(
|
||||
User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0, # 90% active
|
||||
is_staff=i < 5, # 5 staff
|
||||
is_active=i % 10 != 0,
|
||||
is_staff=i < 5,
|
||||
)
|
||||
)
|
||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||
@@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
return request
|
||||
|
||||
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
|
||||
"""
|
||||
Benchmark direct executor calls (simulates WebSocket RPC).
|
||||
|
||||
Returns timing statistics.
|
||||
"""
|
||||
"""Time direct executor calls and return timing statistics."""
|
||||
request = self._make_request()
|
||||
times = []
|
||||
|
||||
@@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
return self._compute_stats(times, f"Executor ({label})")
|
||||
|
||||
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
|
||||
"""
|
||||
Benchmark HTTP view calls.
|
||||
|
||||
Returns timing statistics.
|
||||
"""
|
||||
"""Time HTTP view calls and return timing statistics."""
|
||||
times = []
|
||||
|
||||
# Warmup
|
||||
@@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
self.assertIn("bench", user["email"].lower())
|
||||
|
||||
def test_summary(self):
|
||||
"""Print summary of all benchmarks."""
|
||||
"""Print the legend for the preceding benchmark tables."""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("BENCHMARK SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Iterations per benchmark: {self.ITERATIONS}")
|
||||
print(f"Warmup iterations: {self.WARMUP}")
|
||||
print("\nKey findings:")
|
||||
print("- 'Executor' simulates WebSocket RPC (direct function call)")
|
||||
print("- 'HTTP' measures full request/response cycle")
|
||||
print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch")
|
||||
print("- For I/O-bound operations, protocol overhead is negligible")
|
||||
print("\nColumns:")
|
||||
print("- 'Executor' calls execute_function directly")
|
||||
print("- 'HTTP' calls function_call_view, so it includes JSON parsing,")
|
||||
print(" CSRF handling, and view dispatch")
|
||||
print("=" * 80)
|
||||
|
||||
# Verify bench_simple still produces correct output after all benchmarks
|
||||
@@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
|
||||
|
||||
class ThroughputBenchmark(TransactionTestCase):
|
||||
"""
|
||||
Measure requests per second (throughput) for server functions.
|
||||
|
||||
Tests both sequential and concurrent scenarios.
|
||||
"""
|
||||
"""Requests per second for the executor path versus the HTTP view path."""
|
||||
|
||||
DURATION_SECONDS = 2 # How long to run each throughput test
|
||||
|
||||
@@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
self._create_test_users()
|
||||
|
||||
def _create_test_users(self):
|
||||
"""Create test users for benchmarks."""
|
||||
"""Create 100 users, 90% of them active and 5 of them staff."""
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(
|
||||
@@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
self.assertGreaterEqual(result.data["total_users"], 0)
|
||||
|
||||
def test_throughput_summary(self):
|
||||
"""Print throughput summary."""
|
||||
"""Print the measurement conditions for the preceding throughput tests."""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("THROUGHPUT SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
|
||||
print("\nNotes:")
|
||||
print("- These are single-threaded sequential measurements")
|
||||
print("- Real throughput scales with worker processes (gunicorn -w N)")
|
||||
print("- Database queries are the bottleneck, not protocol overhead")
|
||||
print("- Async workers (uvicorn) can handle more concurrent connections")
|
||||
print("\nConditions:")
|
||||
print("- Single-threaded and sequential")
|
||||
print("- SQLite in-memory database")
|
||||
print("=" * 80)
|
||||
|
||||
# Verify bench_simple still produces correct output after all throughput tests
|
||||
|
||||
@@ -5,11 +5,10 @@ Tests for mizan.channels module.
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.channels import (
|
||||
ReactChannel,
|
||||
Channel,
|
||||
register,
|
||||
get_channel,
|
||||
get_registered_channels,
|
||||
@@ -18,9 +17,6 @@ from mizan.channels import (
|
||||
)
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
@@ -42,52 +38,47 @@ class MockAnonymousUser:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ReactChannel Base Class Tests
|
||||
# Channel Base Class Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannelBaseTests(TestCase):
|
||||
"""Tests for ReactChannel base class."""
|
||||
class ChannelBaseTests(TestCase):
|
||||
"""Tests for Channel base class."""
|
||||
|
||||
def test_react_channel_default_class_vars(self):
|
||||
"""ReactChannel should have None defaults for nested classes."""
|
||||
self.assertIsNone(ReactChannel.Params)
|
||||
self.assertIsNone(ReactChannel.ReactMessage)
|
||||
self.assertIsNone(ReactChannel.DjangoMessage)
|
||||
def test_channel_default_class_vars(self):
|
||||
"""Channel should have None defaults for nested classes."""
|
||||
self.assertIsNone(Channel.Params)
|
||||
self.assertIsNone(Channel.ClientMessage)
|
||||
self.assertIsNone(Channel.ServerMessage)
|
||||
|
||||
def test_react_channel_requires_authorize_override(self):
|
||||
"""ReactChannel subclass must override authorize()."""
|
||||
def test_channel_requires_authorize_override(self):
|
||||
"""A subclass without authorize() cannot be instantiated."""
|
||||
|
||||
class IncompleteChannel(ReactChannel):
|
||||
pass
|
||||
class NoAuthorizeChannel(Channel):
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
channel = IncompleteChannel()
|
||||
channel.user = MockUser()
|
||||
with self.assertRaises(TypeError) as ctx:
|
||||
NoAuthorizeChannel()
|
||||
|
||||
with self.assertRaises(NotImplementedError) as ctx:
|
||||
channel.authorize()
|
||||
self.assertIn("authorize", str(ctx.exception))
|
||||
|
||||
self.assertIn("must implement authorize()", str(ctx.exception))
|
||||
def test_channel_requires_group_override(self):
|
||||
"""A subclass without group() cannot be instantiated."""
|
||||
|
||||
def test_react_channel_requires_group_override(self):
|
||||
"""ReactChannel subclass must override group()."""
|
||||
|
||||
class IncompleteChannel(ReactChannel):
|
||||
class NoGroupChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
channel = IncompleteChannel()
|
||||
channel.user = MockUser()
|
||||
with self.assertRaises(TypeError) as ctx:
|
||||
NoGroupChannel()
|
||||
|
||||
with self.assertRaises(NotImplementedError) as ctx:
|
||||
channel.group()
|
||||
self.assertIn("group", str(ctx.exception))
|
||||
|
||||
self.assertIn("must implement group()", str(ctx.exception))
|
||||
def test_channel_receive_default(self):
|
||||
"""Channel.receive() should return None by default."""
|
||||
|
||||
def test_react_channel_receive_default(self):
|
||||
"""ReactChannel.receive() should return None by default."""
|
||||
|
||||
class BasicChannel(ReactChannel):
|
||||
class BasicChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase):
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_react_channel_init_creates_empty_groups(self):
|
||||
"""ReactChannel.__init__() should create empty _groups set."""
|
||||
def test_channel_init_creates_empty_groups(self):
|
||||
"""Channel.__init__() should create empty _groups set."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase):
|
||||
def test_channel_with_params(self):
|
||||
"""Channel should accept Params Pydantic model."""
|
||||
|
||||
class ParamsChannel(ReactChannel):
|
||||
class ParamsChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
limit: int = 10
|
||||
@@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase):
|
||||
|
||||
self.assertIsNotNone(ParamsChannel.Params)
|
||||
|
||||
# Test params model
|
||||
params = ParamsChannel.Params(room="general")
|
||||
self.assertEqual(params.room, "general")
|
||||
self.assertEqual(params.limit, 10)
|
||||
|
||||
def test_channel_with_react_message(self):
|
||||
"""Channel should accept ReactMessage Pydantic model."""
|
||||
def test_channel_with_client_message(self):
|
||||
"""Channel should accept ClientMessage Pydantic model."""
|
||||
|
||||
class MessageChannel(ReactChannel):
|
||||
class ReactMessage(BaseModel):
|
||||
class MessageChannel(Channel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
timestamp: int
|
||||
|
||||
@@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "messages"
|
||||
|
||||
self.assertIsNotNone(MessageChannel.ReactMessage)
|
||||
self.assertIsNotNone(MessageChannel.ClientMessage)
|
||||
|
||||
# Test message model
|
||||
msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345)
|
||||
msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345)
|
||||
self.assertEqual(msg.text, "Hello")
|
||||
self.assertEqual(msg.timestamp, 12345)
|
||||
|
||||
def test_channel_with_django_message(self):
|
||||
"""Channel should accept DjangoMessage Pydantic model."""
|
||||
def test_channel_with_server_message(self):
|
||||
"""Channel should accept ServerMessage Pydantic model."""
|
||||
|
||||
class BroadcastChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class BroadcastChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
created_at: str
|
||||
@@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "broadcast"
|
||||
|
||||
self.assertIsNotNone(BroadcastChannel.DjangoMessage)
|
||||
self.assertIsNotNone(BroadcastChannel.ServerMessage)
|
||||
|
||||
# Test message model
|
||||
msg = BroadcastChannel.DjangoMessage(
|
||||
msg = BroadcastChannel.ServerMessage(
|
||||
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||
)
|
||||
self.assertEqual(msg.user, "john")
|
||||
@@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase):
|
||||
def test_channel_receive_with_typed_messages(self):
|
||||
"""Channel.receive() should work with typed messages."""
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
|
||||
@@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
||||
return self.ServerMessage(user=self.user.email, text=msg.text)
|
||||
|
||||
channel = ChatChannel()
|
||||
channel.user = MockUser(email="test@example.com")
|
||||
|
||||
params = ChatChannel.Params(room="general")
|
||||
incoming = ChatChannel.ReactMessage(text="Hello!")
|
||||
incoming = ChatChannel.ClientMessage(text="Hello!")
|
||||
|
||||
result = channel.receive(params, incoming)
|
||||
|
||||
self.assertIsInstance(result, ChatChannel.DjangoMessage)
|
||||
self.assertIsInstance(result, ChatChannel.ServerMessage)
|
||||
self.assertEqual(result.user, "test@example.com")
|
||||
self.assertEqual(result.text, "Hello!")
|
||||
|
||||
@@ -243,7 +231,7 @@ class RegistrationTests(TestCase):
|
||||
def test_register_adds_to_registry(self):
|
||||
"""register() should add channel to registry."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -255,17 +243,31 @@ class RegistrationTests(TestCase):
|
||||
self.assertIn("test-channel", _registry)
|
||||
self.assertEqual(_registry["test-channel"], TestChannel)
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
"""register() should raise on duplicate name."""
|
||||
def test_register_sets_registered_name(self):
|
||||
"""register() should stamp the wire name onto the class."""
|
||||
|
||||
class Channel1(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
class Channel2(ReactChannel):
|
||||
register(TestChannel, "named-channel")
|
||||
|
||||
self.assertEqual(TestChannel._registered_name, "named-channel")
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
"""register() should raise on duplicate name."""
|
||||
|
||||
class Channel1(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
class Channel2(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -279,21 +281,10 @@ class RegistrationTests(TestCase):
|
||||
|
||||
self.assertIn("already registered", str(ctx.exception))
|
||||
|
||||
def test_register_validates_authorize(self):
|
||||
"""register() should validate that authorize method exists."""
|
||||
|
||||
class NoAuthorizeChannel(ReactChannel):
|
||||
pass
|
||||
|
||||
# Should still pass because ReactChannel has authorize
|
||||
# (just raises NotImplementedError when called)
|
||||
register(NoAuthorizeChannel, "no-authorize-test")
|
||||
self.assertIn("no-authorize-test", _registry)
|
||||
|
||||
def test_get_channel_returns_registered(self):
|
||||
"""get_channel() should return registered channel."""
|
||||
|
||||
class MyChannel(ReactChannel):
|
||||
class MyChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -315,7 +306,7 @@ class RegistrationTests(TestCase):
|
||||
def test_get_registered_channels_returns_copy(self):
|
||||
"""get_registered_channels() should return a copy of registry."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -326,7 +317,6 @@ class RegistrationTests(TestCase):
|
||||
|
||||
result = get_registered_channels()
|
||||
|
||||
# Modifying result shouldn't affect original
|
||||
result["modified"] = "test"
|
||||
|
||||
self.assertIn("copy-test", _registry)
|
||||
@@ -360,7 +350,7 @@ class SchemaExportTests(TestCase):
|
||||
def test_get_channels_schema_with_basic_channel(self):
|
||||
"""get_channels_schema() should include basic channel info."""
|
||||
|
||||
class BasicChannel(ReactChannel):
|
||||
class BasicChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -376,13 +366,13 @@ class SchemaExportTests(TestCase):
|
||||
|
||||
self.assertEqual(channel_schema["name"], "basic")
|
||||
self.assertIsNone(channel_schema["params"])
|
||||
self.assertIsNone(channel_schema["reactMessage"])
|
||||
self.assertIsNone(channel_schema["djangoMessage"])
|
||||
self.assertIsNone(channel_schema["clientMessage"])
|
||||
self.assertIsNone(channel_schema["serverMessage"])
|
||||
|
||||
def test_get_channels_schema_with_params(self):
|
||||
"""get_channels_schema() should include params schema."""
|
||||
|
||||
class ParamsChannel(ReactChannel):
|
||||
class ParamsChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
limit: int = 50
|
||||
@@ -407,14 +397,14 @@ class SchemaExportTests(TestCase):
|
||||
def test_get_channels_schema_with_messages(self):
|
||||
"""get_channels_schema() should include message schemas."""
|
||||
|
||||
class FullChannel(ReactChannel):
|
||||
class FullChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
channel_id: int
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
timestamp: str
|
||||
@@ -431,24 +421,21 @@ class SchemaExportTests(TestCase):
|
||||
|
||||
channel_schema = schema["channels"]["full-channel"]
|
||||
|
||||
# Check params
|
||||
self.assertIsNotNone(channel_schema["params"])
|
||||
self.assertIn("channel_id", channel_schema["params"]["properties"])
|
||||
|
||||
# Check ReactMessage
|
||||
self.assertIsNotNone(channel_schema["reactMessage"])
|
||||
self.assertIn("text", channel_schema["reactMessage"]["properties"])
|
||||
self.assertIsNotNone(channel_schema["clientMessage"])
|
||||
self.assertIn("text", channel_schema["clientMessage"]["properties"])
|
||||
|
||||
# Check DjangoMessage
|
||||
self.assertIsNotNone(channel_schema["djangoMessage"])
|
||||
self.assertIn("user", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIn("text", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"])
|
||||
self.assertIsNotNone(channel_schema["serverMessage"])
|
||||
self.assertIn("user", channel_schema["serverMessage"]["properties"])
|
||||
self.assertIn("text", channel_schema["serverMessage"]["properties"])
|
||||
self.assertIn("timestamp", channel_schema["serverMessage"]["properties"])
|
||||
|
||||
def test_get_channels_schema_multiple_channels(self):
|
||||
"""get_channels_schema() should include all registered channels."""
|
||||
|
||||
class Channel1(ReactChannel):
|
||||
class Channel1(Channel):
|
||||
class Params(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -458,7 +445,7 @@ class SchemaExportTests(TestCase):
|
||||
def group(self, params):
|
||||
return f"c1_{params.id}"
|
||||
|
||||
class Channel2(ReactChannel):
|
||||
class Channel2(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -473,13 +460,117 @@ class SchemaExportTests(TestCase):
|
||||
self.assertIn("channel-one", schema["channels"])
|
||||
self.assertIn("channel-two", schema["channels"])
|
||||
|
||||
# Channel 1 has params
|
||||
self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
|
||||
|
||||
# Channel 2 has no params
|
||||
self.assertIsNone(schema["channels"]["channel-two"]["params"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registry Extension Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ChannelsExtensionTests(TestCase):
|
||||
"""Tests for the channels extension plugged into mizan_core.registry."""
|
||||
|
||||
def setUp(self):
|
||||
self._original_registry = dict(_registry)
|
||||
|
||||
def tearDown(self):
|
||||
_registry.clear()
|
||||
_registry.update(self._original_registry)
|
||||
|
||||
def _extension(self):
|
||||
from mizan_core.registry import _extensions
|
||||
|
||||
return _extensions["channels"]
|
||||
|
||||
def test_extension_all_returns_registry_copy(self):
|
||||
"""all() should return name -> channel class, decoupled from the registry."""
|
||||
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "ext"
|
||||
|
||||
register(TestChannel, "ext-all")
|
||||
|
||||
result = self._extension().all()
|
||||
|
||||
self.assertEqual(result["ext-all"], TestChannel)
|
||||
|
||||
result["modified"] = TestChannel
|
||||
self.assertNotIn("modified", _registry)
|
||||
|
||||
def test_extension_schema_names_both_directions(self):
|
||||
"""schema() should carry client_message and server_message slots."""
|
||||
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
return True
|
||||
|
||||
def group(self, params):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
register(ChatChannel, "ext-chat")
|
||||
|
||||
entry = self._extension().schema()["ext-chat"]
|
||||
|
||||
self.assertEqual(entry["type"], "channel")
|
||||
self.assertTrue(entry["bidirectional"])
|
||||
self.assertIn("params", entry)
|
||||
self.assertIn("client_message", entry)
|
||||
self.assertIn("server_message", entry)
|
||||
|
||||
def test_extension_schema_omits_absent_client_message(self):
|
||||
"""A server-push-only channel is not bidirectional."""
|
||||
|
||||
class NotificationsChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notifications"
|
||||
|
||||
register(NotificationsChannel, "ext-notifications")
|
||||
|
||||
entry = self._extension().schema()["ext-notifications"]
|
||||
|
||||
self.assertNotIn("client_message", entry)
|
||||
self.assertIn("server_message", entry)
|
||||
self.assertFalse(entry["bidirectional"])
|
||||
|
||||
def test_extension_clear_empties_registry(self):
|
||||
"""clear() should drop every registration."""
|
||||
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "ext"
|
||||
|
||||
register(TestChannel, "ext-clear")
|
||||
|
||||
self._extension().clear()
|
||||
|
||||
self.assertEqual(_registry, {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authorization Tests
|
||||
# =============================================================================
|
||||
@@ -491,7 +582,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_authenticated_user(self):
|
||||
"""authorize() should work with authenticated users."""
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class AuthChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return self.user.is_authenticated
|
||||
|
||||
@@ -506,7 +597,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_anonymous_user(self):
|
||||
"""authorize() should work with anonymous users."""
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class AuthChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return self.user.is_authenticated
|
||||
|
||||
@@ -521,7 +612,7 @@ class AuthorizationTests(TestCase):
|
||||
def test_authorize_with_params(self):
|
||||
"""authorize() should have access to params."""
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
@@ -553,7 +644,7 @@ class GroupTests(TestCase):
|
||||
def test_group_returns_string(self):
|
||||
"""group() should return a string group name."""
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -567,7 +658,7 @@ class GroupTests(TestCase):
|
||||
def test_group_with_params(self):
|
||||
"""group() should use params for dynamic group names."""
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room_id: int
|
||||
|
||||
@@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_join_group() should add group to _groups set."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_leave_group() should remove group from _groups set."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_leave_group() should ignore groups not in _groups."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase):
|
||||
await channel._leave_group("unknown-group")
|
||||
return channel._groups
|
||||
|
||||
groups = asyncio.get_event_loop().run_until_complete(test())
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
# Should not have called group_discard
|
||||
channel._channel_layer.group_discard.assert_not_called()
|
||||
|
||||
def test_leave_all_groups(self):
|
||||
"""_leave_all_groups() should leave all joined groups."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
@@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase):
|
||||
"""_broadcast() should send message to channel layer."""
|
||||
import asyncio
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase):
|
||||
channel = TestChannel()
|
||||
channel._channel_layer = AsyncMock()
|
||||
|
||||
message = TestChannel.DjangoMessage(text="Hello")
|
||||
message = TestChannel.ServerMessage(text="Hello")
|
||||
|
||||
async def test():
|
||||
await channel._broadcast("my-group", message)
|
||||
@@ -747,8 +837,8 @@ class ServerPushTests(TestCase):
|
||||
"""push() should work for channels without params."""
|
||||
import asyncio
|
||||
|
||||
class NotificationChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class NotificationChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
|
||||
@@ -762,7 +852,7 @@ class ServerPushTests(TestCase):
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
message = NotificationChannel.DjangoMessage(
|
||||
message = NotificationChannel.ServerMessage(
|
||||
title="Alert", body="Something happened"
|
||||
)
|
||||
|
||||
@@ -781,11 +871,11 @@ class ServerPushTests(TestCase):
|
||||
"""push() should work for channels with params."""
|
||||
import asyncio
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
@@ -798,7 +888,7 @@ class ServerPushTests(TestCase):
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
message = RoomChannel.DjangoMessage(text="Hello room!")
|
||||
message = RoomChannel.ServerMessage(text="Hello room!")
|
||||
|
||||
async def test():
|
||||
await RoomChannel.push(room="general", message=message)
|
||||
@@ -814,10 +904,9 @@ class ServerPushTests(TestCase):
|
||||
def test_push_without_channel_layer_warns(self):
|
||||
"""push() should warn when no channel layer is configured."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -829,7 +918,7 @@ class ServerPushTests(TestCase):
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||
mock_get_layer.return_value = None
|
||||
|
||||
message = TestChannel.DjangoMessage(text="test")
|
||||
message = TestChannel.ServerMessage(text="test")
|
||||
|
||||
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||
|
||||
@@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase):
|
||||
|
||||
output = out.getvalue()
|
||||
|
||||
# Should be valid JSON with OpenAPI structure
|
||||
schema = json.loads(output)
|
||||
|
||||
self.assertIn("openapi", schema)
|
||||
@@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase):
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase):
|
||||
output = out.getvalue()
|
||||
schema = json.loads(output)
|
||||
|
||||
# Check that channel is in x-mizan-channels metadata
|
||||
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
||||
self.assertIn("export-test", channel_names)
|
||||
|
||||
def test_export_command_names_message_slots(self):
|
||||
"""The x-mizan-channels table should name the client and server slots."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
class SlotChannel(Channel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "slots"
|
||||
|
||||
register(SlotChannel, "slot_channel")
|
||||
|
||||
out = StringIO()
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
schema = json.loads(out.getvalue())
|
||||
entry = next(
|
||||
c for c in schema["x-mizan-channels"] if c["name"] == "slot_channel"
|
||||
)
|
||||
|
||||
self.assertEqual(entry["pascalName"], "SlotChannel")
|
||||
self.assertTrue(entry["hasClientMessage"])
|
||||
self.assertTrue(entry["hasServerMessage"])
|
||||
self.assertEqual(entry["clientMessageType"], "SlotChannelClientMessage")
|
||||
self.assertEqual(entry["serverMessageType"], "SlotChannelServerMessage")
|
||||
|
||||
def test_export_command_type_names_match_the_ir(self):
|
||||
"""A dotted-and-hyphenated wire name yields the same type names the IR
|
||||
emits — the OpenAPI document and the IR describe one set of types."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
from mizan_core.ir import build_ir, wire_to_pascal
|
||||
|
||||
class ActivityFeedChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class ClientMessage(BaseModel):
|
||||
ack: str
|
||||
|
||||
class ServerMessage(BaseModel):
|
||||
event: str
|
||||
|
||||
def authorize(self, params):
|
||||
return True
|
||||
|
||||
def group(self, params):
|
||||
return f"activity_{params.user_id}"
|
||||
|
||||
register(ActivityFeedChannel, "activity.live-feed")
|
||||
|
||||
out = StringIO()
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
schema = json.loads(out.getvalue())
|
||||
entry = next(
|
||||
c for c in schema["x-mizan-channels"] if c["name"] == "activity.live-feed"
|
||||
)
|
||||
|
||||
pascal = wire_to_pascal("activity.live-feed")
|
||||
self.assertEqual(pascal, "ActivityLiveFeed")
|
||||
self.assertEqual(entry["pascalName"], pascal)
|
||||
self.assertEqual(entry["paramsType"], f"{pascal}Params")
|
||||
self.assertEqual(entry["clientMessageType"], f"{pascal}ClientMessage")
|
||||
self.assertEqual(entry["serverMessageType"], f"{pascal}ServerMessage")
|
||||
|
||||
components = schema["components"]["schemas"]
|
||||
ir = build_ir()
|
||||
for slot in ("Params", "ClientMessage", "ServerMessage"):
|
||||
self.assertIn(f"{pascal}{slot}", components)
|
||||
self.assertIn(f'type "{pascal}{slot}"', ir)
|
||||
|
||||
def test_export_command_respects_indent(self):
|
||||
"""export_channels_schema should respect --indent option."""
|
||||
from io import StringIO
|
||||
from django.core.management import call_command
|
||||
|
||||
# With indent
|
||||
out_indent = StringIO()
|
||||
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
||||
|
||||
# Without indent (compact)
|
||||
out_compact = StringIO()
|
||||
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
||||
|
||||
# Indented should be longer (has whitespace)
|
||||
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
||||
|
||||
|
||||
@@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase):
|
||||
"""Tests for WebSocket RPC functionality."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear mizan registry
|
||||
from mizan_core.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
# Register test functions
|
||||
from mizan.client import client
|
||||
from mizan_core.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -27,7 +27,7 @@ from mizan_core.registry import (
|
||||
)
|
||||
from mizan.forms import register_form
|
||||
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.channels import Channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -61,10 +61,9 @@ class ErrorOutput(BaseModel):
|
||||
|
||||
|
||||
def setup_function_style_tests():
|
||||
"""Register function-style test functions.
|
||||
|
||||
Note: Since @client no longer auto-registers (registration happens via
|
||||
mizan_clients() discovery), we explicitly register each function here.
|
||||
"""
|
||||
Register the function-style test functions. Applying @client does not put
|
||||
a function in the registry, so each one is passed to register() here.
|
||||
"""
|
||||
|
||||
@client
|
||||
@@ -514,8 +513,8 @@ class ContextTests(TestCase):
|
||||
fn = get_function("global_context")
|
||||
self.assertEqual(fn._meta.get("context"), "global")
|
||||
|
||||
def test_context_local(self):
|
||||
"""Test @client(context='local') still works with deprecation warning."""
|
||||
def test_context_arbitrary_name_is_verbatim_and_silent(self):
|
||||
"""Any non-empty context string becomes the name verbatim, with no warning."""
|
||||
import warnings
|
||||
|
||||
class CtxOutput(BaseModel):
|
||||
@@ -528,8 +527,7 @@ class ContextTests(TestCase):
|
||||
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
||||
return CtxOutput(data=f"user_{user_id}")
|
||||
|
||||
self.assertEqual(len(w), 1)
|
||||
self.assertIn("deprecated", str(w[0].message).lower())
|
||||
self.assertEqual([str(entry.message) for entry in w], [])
|
||||
|
||||
register(local_context, "local_context")
|
||||
|
||||
@@ -1019,7 +1017,7 @@ class ServerDrivenInvalidationTests(TestCase):
|
||||
self.assertIn("team_info", data)
|
||||
self.assertEqual(data["team_info"]["name"], "team_3")
|
||||
|
||||
# Mizan handles caching via its protocol; origin emits no-store
|
||||
# Origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_error_not_cached(self):
|
||||
@@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase):
|
||||
|
||||
|
||||
class ChannelTests(TestCase):
|
||||
"""Tests for ReactChannel."""
|
||||
"""Tests for Channel."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -1187,8 +1185,8 @@ class ChannelTests(TestCase):
|
||||
"""Test channel registration."""
|
||||
from mizan.channels import register as register_channel, get_channel
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -1201,14 +1199,14 @@ class ChannelTests(TestCase):
|
||||
"""Test channel schema export."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class ChatChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: int
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
class ClientMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
user: str
|
||||
text: str
|
||||
|
||||
@@ -1225,16 +1223,16 @@ class ChannelTests(TestCase):
|
||||
chat_schema = schema["channels"]["chat"]
|
||||
self.assertEqual(chat_schema["type"], "channel")
|
||||
self.assertIn("params", chat_schema)
|
||||
self.assertIn("react_message", chat_schema)
|
||||
self.assertIn("django_message", chat_schema)
|
||||
self.assertIn("client_message", chat_schema)
|
||||
self.assertIn("server_message", chat_schema)
|
||||
self.assertTrue(chat_schema["bidirectional"])
|
||||
|
||||
def test_server_push_only_channel(self):
|
||||
"""Test channel without ReactMessage (server-push only)."""
|
||||
"""Test channel without ClientMessage (server-push only)."""
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class NotificationsChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class NotificationsChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -1244,7 +1242,7 @@ class ChannelTests(TestCase):
|
||||
schema = get_schema()
|
||||
notif_schema = schema["channels"]["notifications"]
|
||||
|
||||
self.assertNotIn("react_message", notif_schema)
|
||||
self.assertNotIn("client_message", notif_schema)
|
||||
self.assertFalse(notif_schema["bidirectional"])
|
||||
|
||||
|
||||
@@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase):
|
||||
"""
|
||||
Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
|
||||
|
||||
This is a regression test for the bug where `UserOutput | None` was
|
||||
incorrectly treated as a primitive type (because Union types aren't
|
||||
recognized by `isinstance(t, type)`), causing the output to be wrapped
|
||||
in a 'result' field.
|
||||
Union types are not recognized by `isinstance(t, type)`, so
|
||||
`UserOutput | None` can be mistaken for a primitive and wrapped in a
|
||||
'result' field. This pins that it is not.
|
||||
"""
|
||||
import types
|
||||
|
||||
@@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase):
|
||||
self.assertFalse(result.data["success"])
|
||||
self.assertIn("errors", result.data)
|
||||
|
||||
def test_form_submit_failure_calls_hook(self):
|
||||
"""A rejected submission calls on_submit_failure with the validation errors."""
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
seen = []
|
||||
|
||||
class HookForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="failure_hook_test")
|
||||
required_field = forms.CharField()
|
||||
|
||||
def on_submit_failure(self, request, errors):
|
||||
seen.append(errors)
|
||||
|
||||
request = self._make_request()
|
||||
result = execute_function(request, "failure_hook_test.submit", {})
|
||||
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertFalse(result.data["success"])
|
||||
self.assertEqual(len(seen), 1)
|
||||
self.assertEqual([entry.field for entry in seen[0].errors], ["required_field"])
|
||||
|
||||
def test_form_meta_serialization(self):
|
||||
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
||||
from mizan.forms import mizanFormMeta
|
||||
@@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase):
|
||||
self.assertEqual(len(result.data["fields"]), 1)
|
||||
self.assertEqual(result.data["fields"][0]["type"], "text")
|
||||
|
||||
def test_default_init_kwargs_forwards_request_only_when_declared(self):
|
||||
"""The base get_init_kwargs passes `request` to a form whose __init__ names it."""
|
||||
from django import forms
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class PlainInitForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="plain_init_test")
|
||||
field = forms.CharField()
|
||||
|
||||
class RequestInitForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="request_init_test")
|
||||
field = forms.CharField()
|
||||
|
||||
def __init__(self, *args, request=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.seen_request = request
|
||||
|
||||
request = self._make_request()
|
||||
|
||||
self.assertEqual(PlainInitForm.get_init_kwargs(request), {})
|
||||
self.assertEqual(RequestInitForm.get_init_kwargs(request), {"request": request})
|
||||
|
||||
# And the form actually constructs with the forwarded kwarg.
|
||||
form = RequestInitForm(**RequestInitForm.get_init_kwargs(request))
|
||||
self.assertIs(form.seen_request, request)
|
||||
|
||||
def test_formset_functions_not_registered_by_default(self):
|
||||
"""Test that formset functions are not registered by default."""
|
||||
from django import forms
|
||||
@@ -1847,7 +1892,7 @@ class HTTPIntegrationTests(TestCase):
|
||||
self.assertEqual(data["user_profile"]["name"], "user_5")
|
||||
self.assertEqual(data["user_orders"]["count"], 50)
|
||||
|
||||
# Mizan handles caching; origin emits no-store
|
||||
# Origin emits no-store
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_context_fetch_string_to_int_coercion(self):
|
||||
@@ -2133,14 +2178,15 @@ class ReturnTypeBranchingTests(TestCase):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Edge Compatibility Tests — Prove CDN caching works before Edge exists
|
||||
# Edge Compatibility Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class EdgeCompatibilityTests(TestCase):
|
||||
"""
|
||||
Tests that prove Edge caching is possible. Every failure mode that
|
||||
would break a CDN layer is tested here without building the CDN.
|
||||
Response properties a CDN layer reads: byte-identical bodies for identical
|
||||
requests, sorted JSON keys, no-store on mutations and errors, and an
|
||||
X-Mizan-Invalidate header that parses back to the JSON body's targets.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Deterministic JSON ──────────────────────────────────────────────────
|
||||
|
||||
def test_deterministic_json_output(self):
|
||||
"""Same request produces byte-identical response body. Cache keys depend on this."""
|
||||
"""Same request produces a byte-identical response body."""
|
||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
|
||||
@@ -2204,12 +2250,12 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Cache-Control correctness ───────────────────────────────────────────
|
||||
|
||||
def test_context_get_no_store(self):
|
||||
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
|
||||
"""Context GET emits no-store."""
|
||||
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_mutation_post_not_cacheable(self):
|
||||
"""Mutation POST has no-store. CDN must never cache mutations."""
|
||||
"""Mutation POST emits no-store."""
|
||||
response = self.client.post(
|
||||
"/api/mizan/call/",
|
||||
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}),
|
||||
@@ -2219,14 +2265,14 @@ class EdgeCompatibilityTests(TestCase):
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_error_response_not_cacheable(self):
|
||||
"""Error responses have no-store. CDN must not cache errors."""
|
||||
"""Error responses emit no-store."""
|
||||
response = self.client.get("/api/mizan/ctx/nonexistent/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response["Cache-Control"], "no-store")
|
||||
|
||||
def test_different_params_different_response(self):
|
||||
"""Different query params produce different response bodies (different cache entries)."""
|
||||
"""Different query params produce different response bodies."""
|
||||
r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
|
||||
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
|
||||
|
||||
@@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
|
||||
header = response["X-Mizan-Invalidate"]
|
||||
|
||||
# Parse the header (this is what Edge would do)
|
||||
# Parse the header back into structured entries
|
||||
entries = []
|
||||
for part in header.split(", "):
|
||||
segments = part.split(";")
|
||||
@@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
# ── Query param ordering doesn't affect content ─────────────────────────
|
||||
|
||||
def test_param_order_irrelevant(self):
|
||||
"""Different query param ordering produces same content (cache key normalization)."""
|
||||
"""Different query param ordering produces the same content."""
|
||||
@client(context=ReactContext("multi"))
|
||||
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
@@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase):
|
||||
]
|
||||
header = _format_invalidate_header(original)
|
||||
|
||||
# Parse (what Edge would do)
|
||||
# Parse back
|
||||
segments = header.split(";")
|
||||
ctx = segments[0]
|
||||
params = {}
|
||||
@@ -3339,7 +3385,8 @@ def _redis_available() -> bool:
|
||||
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
|
||||
client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
37
backends/mizan-django/src/mizan/tests/test_discovery.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Tests for app-root discovery, which decides where mizan_clients() scans.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from mizan.setup.discovery import discover_apps_roots
|
||||
|
||||
# The installed mizan-django package root: `tests` sits directly beneath it and
|
||||
# the virtualenv holding django.contrib.* does too.
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DiscoverAppsRootsTests(TestCase):
|
||||
@override_settings(MIZAN_APPS_ROOT="somewhere_else")
|
||||
def test_pinned_root_is_used_verbatim(self):
|
||||
self.assertEqual(discover_apps_roots(), ["somewhere_else"])
|
||||
|
||||
@override_settings(MIZAN_APPS_ROOT="")
|
||||
def test_pinned_empty_root_means_apps_sit_at_base_dir(self):
|
||||
self.assertEqual(discover_apps_roots(), [""])
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||
def test_top_level_project_app_yields_the_empty_root(self):
|
||||
self.assertIn("", discover_apps_roots())
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT)
|
||||
def test_installed_packages_contribute_no_root(self):
|
||||
# django.contrib.* resolve inside the virtualenv, which lives under
|
||||
# BASE_DIR here — a containment check alone would wrongly admit them.
|
||||
self.assertNotIn("django.contrib", discover_apps_roots())
|
||||
|
||||
@override_settings(BASE_DIR=PACKAGE_ROOT / "no_such_directory")
|
||||
def test_no_matching_app_yields_no_roots(self):
|
||||
self.assertEqual(discover_apps_roots(), [])
|
||||
@@ -1,22 +1,10 @@
|
||||
"""
|
||||
Advanced Penetration Tests for mizan Server Functions
|
||||
Attack-shaped tests over execute_function and the WebSocket consumer.
|
||||
|
||||
These tests simulate a professional security researcher attempting to break
|
||||
the protocol. Focus areas:
|
||||
|
||||
1. Race conditions and TOCTOU vulnerabilities
|
||||
2. Memory exhaustion and resource depletion
|
||||
3. Type confusion at serialization boundaries
|
||||
4. Session/authentication state manipulation
|
||||
5. Pydantic validation bypass attempts
|
||||
6. WebSocket protocol-level attacks
|
||||
7. Timing side-channel attacks
|
||||
8. Concurrent state corruption
|
||||
9. Deserialization attacks
|
||||
10. Unicode normalization exploits
|
||||
|
||||
SAFE TO RUN: These tests don't execute actual exploits - they verify
|
||||
that the defenses hold against attack patterns.
|
||||
Grouped by the surface each one drives: memory exhaustion, type confusion at
|
||||
the serialization boundary, concurrent execution, Pydantic validation bypass,
|
||||
WebSocket protocol framing, timing measurement, Unicode normalization, JSON
|
||||
parsing limits, authorization boundaries, and registration collisions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -485,9 +473,6 @@ class RaceConditionTests(TestCase):
|
||||
|
||||
result = execute_function(request, "timed_auth_func", None)
|
||||
|
||||
# The result reflects the state at time of check
|
||||
# This test documents the behavior - the user's is_authenticated
|
||||
# is read during execution, and if it changes, that's reflected
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
# On first access is_authenticated returns True, on second it returns False
|
||||
# The function reads is_authenticated once, so it sees the first value (True)
|
||||
@@ -503,9 +488,8 @@ class PydanticBypassTests(TestCase):
|
||||
"""
|
||||
Attempt to bypass Pydantic validation.
|
||||
|
||||
Note: The @client decorator creates dynamic Pydantic models from function
|
||||
parameters. Custom validators must be on the parameter types themselves,
|
||||
not separate classes. This tests the actual validation behavior.
|
||||
The @client decorator builds the Input model from the function's parameter
|
||||
annotations, so a custom validator has to live on the parameter type itself.
|
||||
|
||||
Attack vectors:
|
||||
- Type coercion bypass
|
||||
@@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase):
|
||||
|
||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||
"""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel, Channel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class TestChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
@@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase):
|
||||
"""
|
||||
Test attempting to subscribe to the same channel twice.
|
||||
"""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel, Channel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
|
||||
class TestChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class TestChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase):
|
||||
# Large differences could leak function existence
|
||||
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent)
|
||||
|
||||
# Document the ratio but don't fail - this is informational
|
||||
print(f"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
|
||||
print(f"Avg existing: {avg_existing*1000:.3f}ms")
|
||||
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
|
||||
@@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase):
|
||||
"""
|
||||
request = self._make_request()
|
||||
|
||||
# These look like "admin" but use different Unicode characters
|
||||
# Built from chr(): each of these renders identically to its ASCII
|
||||
# counterpart, so a literal would be unreadable in source.
|
||||
lookalikes = [
|
||||
"\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a'
|
||||
"adm\u0131n", # Turkish dotless i (U+0131)
|
||||
"\u00e1dmin", # Latin a with acute
|
||||
"\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters
|
||||
"\u0251dmin", # Latin alpha
|
||||
"\u0430\u0501m\u0456n", # Mix of Cyrillic characters
|
||||
chr(0x0430) + "dmin", # Cyrillic small a
|
||||
"adm" + chr(0x0131) + "n", # Turkish dotless i
|
||||
chr(0x00E1) + "dmin", # Latin a with acute
|
||||
"".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)),
|
||||
chr(0x0251) + "dmin", # Latin alpha
|
||||
chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix
|
||||
]
|
||||
|
||||
for lookalike in lookalikes:
|
||||
@@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase):
|
||||
|
||||
request = self._make_request()
|
||||
|
||||
# é can be represented as:
|
||||
# 1. U+00E9 (precomposed)
|
||||
# 2. U+0065 U+0301 (decomposed: e + combining acute)
|
||||
precomposed = "caf\u00e9" # café with precomposed é
|
||||
decomposed = "cafe\u0301" # café with combining acute
|
||||
# Built from chr() so the two spellings stay distinguishable in source:
|
||||
# U+00E9 precomposed vs. "e" + U+0301 combining acute.
|
||||
precomposed = "caf" + chr(0x00E9)
|
||||
decomposed = "cafe" + chr(0x0301)
|
||||
|
||||
# These look identical but are different byte sequences
|
||||
self.assertNotEqual(precomposed, decomposed)
|
||||
@@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase):
|
||||
"""
|
||||
request = self._make_request()
|
||||
|
||||
# Built from chr(): every one of these renders as nothing at all.
|
||||
zero_width_chars = [
|
||||
"\u200b", # Zero-width space
|
||||
"\u200c", # Zero-width non-joiner
|
||||
"\u200d", # Zero-width joiner
|
||||
"\u2060", # Word joiner
|
||||
"\ufeff", # Zero-width no-break space (BOM)
|
||||
chr(0x200B), # Zero-width space
|
||||
chr(0x200C), # Zero-width non-joiner
|
||||
chr(0x200D), # Zero-width joiner
|
||||
chr(0x2060), # Word joiner
|
||||
chr(0xFEFF), # Zero-width no-break space (BOM)
|
||||
]
|
||||
|
||||
for zwc in zero_width_chars:
|
||||
@@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase):
|
||||
|
||||
try:
|
||||
result = execute_function(request, "json_func", {"data": nested})
|
||||
# Should either succeed or fail gracefully
|
||||
self.assertIn(type(result), [FunctionResult, FunctionError])
|
||||
except RecursionError:
|
||||
# This is acceptable - Python's recursion limit hit
|
||||
pass
|
||||
except RecursionError as exc:
|
||||
print(f"\nCPython recursion limit reached at 500 levels: {exc}")
|
||||
|
||||
def test_json_number_precision(self):
|
||||
"""
|
||||
@@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase):
|
||||
"""
|
||||
Test that a different function cannot override an existing one.
|
||||
|
||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
||||
But a DIFFERENT function cannot take over an existing name.
|
||||
Re-registering the same object under its own name is allowed; a
|
||||
different object claiming a taken name raises.
|
||||
"""
|
||||
from mizan.client import ServerFunction
|
||||
from mizan_core.registry import register
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
"""
|
||||
Security-focused E2E tests for mizan server functions.
|
||||
|
||||
These tests probe for potential vulnerabilities without running any
|
||||
malicious code - they simply verify that defenses work correctly.
|
||||
|
||||
Security areas covered:
|
||||
1. Input Validation - Large inputs, nested objects, type confusion
|
||||
2. Authorization - Bypass attempts, permission checks
|
||||
3. HTTP Endpoint - CSRF, method restrictions, JSON parsing
|
||||
4. WebSocket RPC - Malformed messages, unauthorized calls
|
||||
5. Information Disclosure - Error enumeration, internal detail leakage
|
||||
6. Injection Prevention - Special characters, unicode edge cases
|
||||
Adversarial-input tests: hostile payloads driven through execute_function,
|
||||
function_call_view, and the WebSocket consumer.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, Client, override_settings
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
@@ -29,9 +19,9 @@ from mizan.client.executor import (
|
||||
execute_function,
|
||||
function_call_view,
|
||||
)
|
||||
from mizan_core.registry import clear_registry, register, register_as, get_function
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan_core.registry import clear_registry, register
|
||||
from mizan.client import client
|
||||
from mizan.channels import Channel
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -46,10 +36,6 @@ class SimpleOutput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class NestedInput(BaseModel):
|
||||
level1: dict
|
||||
|
||||
|
||||
class DeeplyNestedOutput(BaseModel):
|
||||
depth: int
|
||||
|
||||
@@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel):
|
||||
|
||||
|
||||
class InputValidationSecurityTests(TestCase):
|
||||
"""
|
||||
Test input validation for security edge cases.
|
||||
|
||||
Verifies that Pydantic validation catches malicious or malformed input
|
||||
BEFORE any function code executes.
|
||||
"""
|
||||
"""Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase):
|
||||
"""Test that null bytes in strings are handled safely."""
|
||||
request = self._make_request()
|
||||
|
||||
# Null byte injection attempt
|
||||
payload = "normal\x00injected"
|
||||
result = execute_function(request, "echo_any", {"message": payload})
|
||||
|
||||
@@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase):
|
||||
"""Test various unicode edge cases."""
|
||||
request = self._make_request()
|
||||
|
||||
# Escapes, not literals: these codepoints are invisible in an editor, and
|
||||
# a literal NUL cannot appear in Python source at all.
|
||||
test_cases = [
|
||||
# Zero-width characters
|
||||
"normal\u200btext",
|
||||
"normaltext",
|
||||
# Right-to-left override (potential display issues)
|
||||
"test\u202eevil",
|
||||
"testevil",
|
||||
# Emoji sequences
|
||||
"👨👩👧👦",
|
||||
"\U0001f468\U0001f469\U0001f467\U0001f466",
|
||||
# Combining characters
|
||||
"a\u0300\u0301\u0302",
|
||||
"à́̂",
|
||||
# Null character
|
||||
"test\u0000null",
|
||||
"test\x00null",
|
||||
# Replacement character
|
||||
"test\ufffdreplace",
|
||||
"test<EFBFBD>replace",
|
||||
]
|
||||
|
||||
for payload in test_cases:
|
||||
@@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase):
|
||||
|
||||
|
||||
class AuthorizationSecurityTests(TestCase):
|
||||
"""
|
||||
Test authorization bypass attempts.
|
||||
|
||||
Verifies that authentication/authorization checks can't be bypassed
|
||||
through various attack vectors.
|
||||
"""
|
||||
"""execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
@client
|
||||
def leaky_auth_check(request: HttpRequest) -> SimpleOutput:
|
||||
# Bad pattern: returns different errors for auth vs not found
|
||||
# This is intentionally bad to test we detect it
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("User not logged in")
|
||||
return SimpleOutput(value="ok")
|
||||
@@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase):
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
|
||||
def test_spoofed_is_authenticated_attribute(self):
|
||||
"""Test that spoofing is_authenticated doesn't work."""
|
||||
"""Test that a duck-typed user carrying is_authenticated is accepted."""
|
||||
|
||||
# Create object that claims to be authenticated but isn't a real user
|
||||
class FakeUser:
|
||||
is_authenticated = True
|
||||
id = 999
|
||||
@@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
request = self._make_request(user=FakeUser())
|
||||
result = execute_function(request, "requires_auth", None)
|
||||
|
||||
# This actually works because we only check is_authenticated
|
||||
# This test documents the behavior - real Django handles this
|
||||
# execute_function only reads is_authenticated, so this duck-type passes
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
|
||||
def test_user_id_manipulation_blocked(self):
|
||||
@@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
@client
|
||||
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
||||
# Properly checking: can only access own data
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
if request.user.id != target_user_id:
|
||||
@@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
|
||||
class HTTPEndpointSecurityTests(TestCase):
|
||||
"""
|
||||
Test HTTP endpoint security.
|
||||
|
||||
Verifies CSRF protection, method restrictions, and JSON parsing security.
|
||||
"""
|
||||
"""Method restrictions, JSON body parsing, and function-name lookup on the HTTP view."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
# Bypass CSRF for this test
|
||||
request._dont_enforce_csrf_checks = True
|
||||
|
||||
response = function_call_view(request)
|
||||
@@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
def test_function_identifier_traversal(self):
|
||||
"""Test that path traversal-style function identifiers are handled."""
|
||||
# Try various path traversal attempts as function identifiers
|
||||
malicious_names = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\windows\\system32",
|
||||
@@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
|
||||
class WebSocketRPCSecurityTests(TestCase):
|
||||
"""
|
||||
Test WebSocket RPC security.
|
||||
|
||||
Verifies that malformed messages and unauthorized calls are handled safely.
|
||||
"""
|
||||
"""Malformed and unresolvable RPC frames over the WebSocket consumer."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
consumer.channel_layer = MagicMock()
|
||||
consumer.channel_name = "test"
|
||||
|
||||
# Track sent messages
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call without id
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||
)
|
||||
@@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call without fn
|
||||
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
|
||||
|
||||
# Should return error
|
||||
self.assertEqual(len(sent_messages), 1)
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
|
||||
@@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call with wrong input type
|
||||
# Pydantic coerces an int to str, so an omitted required field is what
|
||||
# actually produces a validation error here.
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{
|
||||
"id": "123",
|
||||
"fn": "ws_echo",
|
||||
"args": {"message": 12345}, # Should be string
|
||||
"args": {"message": 12345},
|
||||
}
|
||||
)
|
||||
|
||||
# Pydantic coerces int to string, so this actually succeeds
|
||||
# Let's test with missing required field instead
|
||||
sent_messages.clear()
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
||||
{"id": "124", "fn": "ws_echo", "args": {}}
|
||||
)
|
||||
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
@@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
|
||||
|
||||
class InformationDisclosureTests(TestCase):
|
||||
"""
|
||||
Test information disclosure vulnerabilities.
|
||||
|
||||
Verifies that error messages don't leak sensitive information.
|
||||
"""
|
||||
"""Contents of FunctionError responses with DEBUG=False."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase):
|
||||
|
||||
@client
|
||||
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
|
||||
# Simulate accessing sensitive config that might leak in error
|
||||
secret_key = "super_secret_key_12345"
|
||||
raise RuntimeError(f"Database error with key: {secret_key}")
|
||||
|
||||
@@ -703,7 +655,6 @@ class InformationDisclosureTests(TestCase):
|
||||
"""Test that error messages don't help enumerate functions in production."""
|
||||
request = self._make_request()
|
||||
|
||||
# Try various function names/UUIDs
|
||||
test_names = [
|
||||
"admin_panel",
|
||||
"get_all_users",
|
||||
@@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase):
|
||||
result = execute_function(request, name, None)
|
||||
self.assertIsInstance(result, FunctionError)
|
||||
self.assertEqual(result.code, ErrorCode.NOT_FOUND)
|
||||
# In production (DEBUG=False), error message is generic
|
||||
# - doesn't reveal function name or UUID existence
|
||||
# With DEBUG=False the message is identical for every name
|
||||
self.assertEqual(result.message, "Function not found")
|
||||
|
||||
def test_validation_errors_dont_leak_internals(self):
|
||||
@@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase):
|
||||
request = self._make_request()
|
||||
result = execute_function(request, "validated_func", {"secret_field": 123})
|
||||
|
||||
# Pydantic coerces to string, so let's try with wrong structure
|
||||
# Pydantic coerces to string, so an unknown field is what fails here.
|
||||
result = execute_function(request, "validated_func", {"wrong_field": "test"})
|
||||
|
||||
self.assertIsInstance(result, FunctionError)
|
||||
@@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase):
|
||||
|
||||
|
||||
class InjectionPreventionTests(TestCase):
|
||||
"""
|
||||
Test injection attack prevention.
|
||||
|
||||
Verifies that input validation prevents various injection attacks.
|
||||
Note: These tests verify the framework's security, not actual injection
|
||||
attempts - they just ensure malicious input is handled safely.
|
||||
"""
|
||||
"""SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
@client
|
||||
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
||||
# This function just echoes - the test is about validation
|
||||
return SimpleOutput(value=user_input)
|
||||
|
||||
register(echo_safe, "echo_safe")
|
||||
@@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
for payload in sql_payloads:
|
||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||
# Should succeed - it's just a string, not executed as SQL
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
# The payload is returned as-is (no SQL execution)
|
||||
self.assertEqual(result.data["value"], payload)
|
||||
|
||||
def test_command_injection_in_string_field(self):
|
||||
@@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
for payload in cmd_payloads:
|
||||
result = execute_function(request, "echo_safe", {"user_input": payload})
|
||||
# Should succeed - it's just a string
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["value"], payload)
|
||||
|
||||
@@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase):
|
||||
"""Test that special JSON values are handled safely."""
|
||||
request = self._make_request()
|
||||
|
||||
# Various JSON edge cases
|
||||
test_cases = [
|
||||
{"__proto__": {"polluted": True}},
|
||||
{"constructor": {"prototype": {}}},
|
||||
{"key": None},
|
||||
{"key": float("inf")}, # This will fail JSON serialization
|
||||
{"key": float("inf")},
|
||||
]
|
||||
|
||||
for data in test_cases:
|
||||
@@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase):
|
||||
|
||||
|
||||
class ChannelAuthorizationTests(TestCase):
|
||||
"""
|
||||
Test WebSocket channel authorization.
|
||||
|
||||
Verifies that channel subscriptions properly check permissions.
|
||||
"""
|
||||
"""Subscription outcomes when authorize() returns False, raises, or gets bad params."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
# Also clear the channels registry
|
||||
from mizan.channels import _registry as channels_registry
|
||||
|
||||
channels_registry.clear()
|
||||
@@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def _register_test_channels(self):
|
||||
"""Register test channels using the channels module's register."""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
class PublicChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class PublicChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "public"
|
||||
|
||||
class AuthChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class AuthChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "auth"
|
||||
|
||||
class RoomChannel(ReactChannel):
|
||||
class RoomChannel(Channel):
|
||||
class Params(BaseModel):
|
||||
room_id: int
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params):
|
||||
# Only allow access to room 1 and 2
|
||||
return params.room_id in [1, 2]
|
||||
|
||||
def group(self, params):
|
||||
@@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def test_authorize_exception_handling(self):
|
||||
"""Test that exceptions in authorize() are handled safely."""
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
class ErrorChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
class ErrorChannel(Channel):
|
||||
class ServerMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
@@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase):
|
||||
{"channel": "auth-channel", "params": {}}
|
||||
)
|
||||
|
||||
# Should be rejected
|
||||
self.assertIn("error", sent_messages[0])
|
||||
self.assertIn("Not authorized", sent_messages[0]["error"])
|
||||
|
||||
@@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase):
|
||||
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||
)
|
||||
|
||||
# Should fail validation
|
||||
self.assertIn("error", sent_messages[0])
|
||||
|
||||
def test_room_authorization_enforced(self):
|
||||
@@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
|
||||
class AbusePreventionTests(TestCase):
|
||||
"""
|
||||
Test abuse prevention capabilities.
|
||||
|
||||
Note: The current implementation doesn't have built-in rate limiting,
|
||||
so these tests document the expected behavior and identify areas
|
||||
where rate limiting should be added.
|
||||
"""
|
||||
"""Repeated and batched execute_function calls."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase):
|
||||
"""Test that rapid function calls don't cause issues."""
|
||||
request = self._make_request()
|
||||
|
||||
# Make 100 rapid calls
|
||||
results = []
|
||||
for _ in range(100):
|
||||
result = execute_function(request, "simple_func", None)
|
||||
results.append(result)
|
||||
|
||||
# All should succeed (no rate limiting currently) and return expected data
|
||||
for result in results:
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["value"], "ok")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Tests for the Mizan SSR bridge and template backend.
|
||||
Tests for the SSR bridge and the MizanTemplates Django template backend.
|
||||
|
||||
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
|
||||
Tests skip gracefully if Bun is not available.
|
||||
The bridge shells out to Bun, so every test here skips unless `bun` is on PATH
|
||||
and the worker script is present.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -11,13 +11,12 @@ import threading
|
||||
|
||||
from django.test import SimpleTestCase, RequestFactory
|
||||
|
||||
# Path to the test worker
|
||||
_SSR_WORKER = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..", "..", "..", "..", "..", # up to repo root
|
||||
"packages", "mizan-ssr", "src", "test-worker.tsx",
|
||||
_REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
|
||||
)
|
||||
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
|
||||
_SSR_WORKER = os.path.join(_REPO_ROOT, "workers", "mizan-ssr", "src", "worker.tsx")
|
||||
_COMPONENT_DIR = os.path.join(os.path.dirname(__file__), "ssr_components")
|
||||
_HELLO = os.path.join(_COMPONENT_DIR, "Hello.tsx")
|
||||
|
||||
_BUN_AVAILABLE = shutil.which("bun") is not None
|
||||
_SKIP_MSG = "Bun not available"
|
||||
@@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.bridge import SSRBridge
|
||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
|
||||
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=10.0)
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "bridge"):
|
||||
self.bridge.shutdown()
|
||||
|
||||
def test_ping(self):
|
||||
"""Worker starts and responds to ping."""
|
||||
self.assertTrue(self.bridge.ping())
|
||||
|
||||
def test_render_simple(self):
|
||||
"""Renders a simple component to HTML."""
|
||||
result = self.bridge.render("Hello", {"name": "World"})
|
||||
def test_render_starts_worker_and_returns_html(self):
|
||||
"""The first render boots the worker and returns rendered markup."""
|
||||
result = self.bridge.render(_HELLO, {"name": "World"})
|
||||
self.assertIn("Hello,", result.html)
|
||||
self.assertIn("World", result.html)
|
||||
|
||||
def test_render_with_props(self):
|
||||
"""Renders a component with multiple props."""
|
||||
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
|
||||
def test_render_passes_props_through(self):
|
||||
"""Props reach the component."""
|
||||
result = self.bridge.render(_HELLO, {"name": "Alice"})
|
||||
self.assertIn("Alice", result.html)
|
||||
self.assertIn("42", result.html)
|
||||
self.assertIn('data-mizan-component="Hello"', result.html)
|
||||
|
||||
def test_render_missing_component(self):
|
||||
"""Rendering an unregistered component raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("NonExistent", {})
|
||||
self.assertIn("not registered", str(ctx.exception))
|
||||
|
||||
def test_render_error(self):
|
||||
"""Component that throws during render raises RuntimeError."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.bridge.render("Broken", {})
|
||||
self.assertIn("Render error", str(ctx.exception))
|
||||
def test_render_missing_file_raises(self):
|
||||
"""Rendering a path with no module raises RuntimeError naming the failure."""
|
||||
missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.bridge.render(missing, {})
|
||||
|
||||
def test_crash_recovery(self):
|
||||
"""Bridge restarts the worker if it dies."""
|
||||
# First render works
|
||||
result = self.bridge.render("Hello", {"name": "Before"})
|
||||
"""The bridge restarts the worker if it dies."""
|
||||
result = self.bridge.render(_HELLO, {"name": "Before"})
|
||||
self.assertIn("Before", result.html)
|
||||
|
||||
# Kill the subprocess
|
||||
self.bridge._proc.kill()
|
||||
self.bridge._proc.wait()
|
||||
|
||||
# Next render should restart and work
|
||||
result = self.bridge.render("Hello", {"name": "After"})
|
||||
result = self.bridge.render(_HELLO, {"name": "After"})
|
||||
self.assertIn("After", result.html)
|
||||
|
||||
def test_concurrent_renders(self):
|
||||
"""Multiple threads can render simultaneously."""
|
||||
"""Concurrent callers each get their own response matched by message id."""
|
||||
results = {}
|
||||
errors = {}
|
||||
|
||||
def render_in_thread(name: str, idx: int):
|
||||
try:
|
||||
result = self.bridge.render("Hello", {"name": name})
|
||||
results[idx] = result.html
|
||||
results[idx] = self.bridge.render(_HELLO, {"name": name}).html
|
||||
except Exception as e:
|
||||
errors[idx] = e
|
||||
|
||||
@@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase):
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
t.join(timeout=20)
|
||||
|
||||
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
|
||||
self.assertEqual(errors, {})
|
||||
self.assertEqual(len(results), 5)
|
||||
for i in range(5):
|
||||
self.assertIn(f"User{i}", results[i])
|
||||
@@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
||||
if not _BUN_AVAILABLE:
|
||||
self.skipTest(_SKIP_MSG)
|
||||
if not os.path.exists(_SSR_WORKER):
|
||||
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
|
||||
self.skipTest(f"SSR worker not found at {_SSR_WORKER}")
|
||||
|
||||
from mizan.ssr.backend import MizanTemplates
|
||||
self.engine = MizanTemplates({
|
||||
"NAME": "mizan-test",
|
||||
"DIRS": [],
|
||||
"DIRS": [_COMPONENT_DIR],
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"worker_path": _SSR_WORKER,
|
||||
"timeout": 5,
|
||||
"worker": _SSR_WORKER,
|
||||
"timeout": 10,
|
||||
},
|
||||
})
|
||||
self.factory = RequestFactory()
|
||||
@@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase):
|
||||
if hasattr(self, "engine") and self.engine._bridge is not None:
|
||||
self.engine._bridge.shutdown()
|
||||
|
||||
def test_get_template(self):
|
||||
"""get_template returns a MizanTemplate."""
|
||||
def test_get_template_resolves_name_to_file(self):
|
||||
"""get_template resolves the name against DIRS to an absolute file path."""
|
||||
from mizan.ssr.backend import MizanTemplate
|
||||
template = self.engine.get_template("Hello")
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
self.assertIsInstance(template, MizanTemplate)
|
||||
self.assertEqual(template.component_name, "Hello")
|
||||
self.assertEqual(template.file_path, os.path.abspath(_HELLO))
|
||||
|
||||
def test_template_render(self):
|
||||
"""MizanTemplate.render() produces HTML."""
|
||||
template = self.engine.get_template("Hello")
|
||||
def test_missing_template_raises(self):
|
||||
"""A name that resolves to no file under DIRS raises TemplateDoesNotExist."""
|
||||
from django.template import TemplateDoesNotExist
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
self.engine.get_template("NoSuchComponent.tsx")
|
||||
|
||||
def test_template_render_emits_html_and_hydration_data(self):
|
||||
"""render() wraps the markup and serializes the props for hydration."""
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
html = template.render({"name": "Django"})
|
||||
self.assertIn("Hello,", html)
|
||||
self.assertIn("Django", html)
|
||||
self.assertIn('data-mizan-component="Hello"', html)
|
||||
self.assertIn('id="mizan-root"', html)
|
||||
self.assertIn('window.__MIZAN_SSR_DATA__={"name": "Django"}', html)
|
||||
|
||||
def test_template_render_strips_django_internals(self):
|
||||
"""Django-internal context keys (request, csrf_token) are not passed as props."""
|
||||
template = self.engine.get_template("Hello")
|
||||
"""request and csrf_token are dropped from props and from hydration data."""
|
||||
template = self.engine.get_template("Hello.tsx")
|
||||
request = self.factory.get("/")
|
||||
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
|
||||
html = template.render(
|
||||
{"name": "Test", "request": request, "csrf_token": "abc"}, request
|
||||
)
|
||||
self.assertIn("Test", html)
|
||||
self.assertNotIn("csrf_token", html)
|
||||
self.assertNotIn("abc", html)
|
||||
|
||||
def test_from_string_raises(self):
|
||||
"""from_string is not supported."""
|
||||
from django.template import TemplateDoesNotExist
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
def test_from_string_is_unsupported(self):
|
||||
"""This engine renders modules by path, so it has no source-string form."""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.engine.from_string("<div>Not supported</div>")
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
"""
|
||||
mizan URL Configuration
|
||||
|
||||
HTTP endpoints:
|
||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
||||
- POST /call/ - Server function calls (HTTP transport)
|
||||
- GET /ctx/<name>/ - Bundled context fetch (all functions in a named context)
|
||||
|
||||
Security:
|
||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
||||
- Use the management command instead: python manage.py export_mizan_ir
|
||||
mizan's HTTP endpoints: session bootstrap, the server-function call endpoint,
|
||||
and the bundled per-context fetch. Schema export is reachable only through the
|
||||
`export_mizan_ir` management command, never over HTTP.
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
@@ -16,7 +9,7 @@ from django.middleware.csrf import get_token
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
||||
from .client.executor import function_call_view, context_fetch_view
|
||||
from mizan.client.executor import function_call_view, context_fetch_view
|
||||
|
||||
app_name = "mizan"
|
||||
|
||||
@@ -24,13 +17,8 @@ app_name = "mizan"
|
||||
@ensure_csrf_cookie
|
||||
def session_init_view(request):
|
||||
"""
|
||||
Initialize a Django session and return the CSRF token.
|
||||
|
||||
Used by SSR to establish a session before making authenticated requests.
|
||||
The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set.
|
||||
|
||||
Returns:
|
||||
{ "csrfToken": "..." }
|
||||
Start a Django session and return `{"csrfToken": ...}`. The decorator is
|
||||
what puts the csrftoken cookie on the response.
|
||||
"""
|
||||
return JsonResponse({"csrfToken": get_token(request)})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
BaseUserManager,
|
||||
@@ -7,8 +9,6 @@ from django.db import models
|
||||
|
||||
|
||||
class EmailUserManager(BaseUserManager):
|
||||
"""Custom user manager using email as the unique identifier."""
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError("Email is required")
|
||||
@@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager):
|
||||
|
||||
|
||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
"""Minimal user model with email as USERNAME_FIELD.
|
||||
|
||||
Matches the calling convention used in mizan's test suite:
|
||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
||||
"""
|
||||
|
||||
email = models.EmailField(unique=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
@@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
app_label = "tests"
|
||||
|
||||
|
||||
# ─── Shape test models ──────────────────────────────────────────────────────
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class TimestampMixin(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
"""
|
||||
Django settings for running mizan's test suite standalone.
|
||||
|
||||
Usage:
|
||||
cd django/
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
"""
|
||||
|
||||
SECRET_KEY = "test-secret-key-for-standalone-tests-only"
|
||||
|
||||
DEBUG = True
|
||||
@@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# JWT settings for test_auth.py (can be overridden per-class with @override_settings)
|
||||
JWT_PRIVATE_KEY = "test-secret-key-for-testing-only"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# Session engine (for test_auth.py SessionStore usage)
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
"""
|
||||
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
|
||||
|
||||
HTTP RPC dispatch, context bundling, and a WebSocket carrying channel
|
||||
subscriptions and RPC on mizan-django's envelope. Forms, Shapes and SSR are
|
||||
out of scope — FastAPI projects use native equivalents.
|
||||
|
||||
Usage:
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.include_router(ws_router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
|
||||
# Register your @client-decorated functions
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.registry import register
|
||||
from .my_functions import echo
|
||||
register(echo, "echo")
|
||||
Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket),
|
||||
the error hierarchy with its exception handlers, and the channel base class with
|
||||
its registry.
|
||||
"""
|
||||
|
||||
from .executor import (
|
||||
from mizan_fastapi.executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
@@ -34,14 +19,19 @@ from .executor import (
|
||||
compute_invalidation,
|
||||
execute_function,
|
||||
)
|
||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from .websocket import ws_router
|
||||
from .channels import ReactChannel, broadcast, get_channel, register as register_channel
|
||||
from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler
|
||||
from mizan_fastapi.websocket import ws_router
|
||||
from mizan_fastapi.channels import (
|
||||
Channel,
|
||||
broadcast,
|
||||
get_channel,
|
||||
register as register_channel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ws_router",
|
||||
"ReactChannel",
|
||||
"Channel",
|
||||
"register_channel",
|
||||
"get_channel",
|
||||
"broadcast",
|
||||
|
||||
@@ -5,10 +5,8 @@ A channel names a group of subscribers and decides who may join it. `group(param
|
||||
is the fan-out key, so two subscribers with the same params share a group and a push
|
||||
addressed to those params reaches both.
|
||||
|
||||
Membership is held in this process. Django's channel layer carries groups across
|
||||
workers through Redis; nothing here does, so a push reaches only the subscribers
|
||||
connected to the process that sent it. One process — a desktop shell, a single
|
||||
uvicorn worker — is the shape this serves.
|
||||
Membership is held in this process. A push therefore reaches only the subscribers
|
||||
whose socket is attached to the process that sent it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,20 +18,27 @@ from typing import Any, ClassVar
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.registry import RegistryExtension, register_extension
|
||||
|
||||
# group name -> the live sockets subscribed to it
|
||||
_groups: dict[str, set[Any]] = defaultdict(set)
|
||||
_registry: dict[str, type["ReactChannel"]] = {}
|
||||
_registry: dict[str, type["Channel"]] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
class Channel:
|
||||
"""A named fan-out. Subclass, override what the channel decides, and register it.
|
||||
|
||||
A subclass may define `on_connect(params)` / `on_disconnect()`; the socket handler
|
||||
calls them when they exist.
|
||||
The three nested models are the channel's payload types, named from the client's
|
||||
side: `Params` keys the fan-out, `ClientMessage` travels up, `ServerMessage`
|
||||
travels down. A subclass may also define `on_connect(params)` / `on_disconnect()`;
|
||||
the socket handler calls them when they exist.
|
||||
"""
|
||||
|
||||
name: ClassVar[str] = ""
|
||||
Params: ClassVar[type[BaseModel] | None] = None
|
||||
ClientMessage: ClassVar[type[BaseModel] | None] = None
|
||||
ServerMessage: ClassVar[type[BaseModel] | None] = None
|
||||
|
||||
def authorize(self, params: BaseModel | None = None) -> bool:
|
||||
"""Whether this subscriber may join. Default: anyone may."""
|
||||
@@ -68,16 +73,16 @@ class _Params:
|
||||
return self._values
|
||||
|
||||
|
||||
def register(channel_class: type[ReactChannel], name: str) -> None:
|
||||
def register(channel_class: type[Channel], name: str) -> None:
|
||||
channel_class.name = name
|
||||
_registry[name] = channel_class
|
||||
|
||||
|
||||
def get_channel(name: str) -> type[ReactChannel] | None:
|
||||
def get_channel(name: str) -> type[Channel] | None:
|
||||
return _registry.get(name)
|
||||
|
||||
|
||||
def registered() -> dict[str, type[ReactChannel]]:
|
||||
def registered() -> dict[str, type[Channel]]:
|
||||
return dict(_registry)
|
||||
|
||||
|
||||
@@ -122,3 +127,36 @@ async def broadcast(
|
||||
except Exception as e:
|
||||
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
|
||||
await leave(group, socket)
|
||||
|
||||
|
||||
class _ChannelsExtension(RegistryExtension):
|
||||
"""The `channels` slot of the core registry — one entry per registered channel,
|
||||
each carrying the JSON schema of whichever payload models the channel declares.
|
||||
"""
|
||||
|
||||
def all(self) -> dict[str, type[Channel]]:
|
||||
return dict(_registry)
|
||||
|
||||
def schema(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for name, channel_class in _registry.items():
|
||||
entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": "channel",
|
||||
"bidirectional": False,
|
||||
}
|
||||
if channel_class.Params is not None:
|
||||
entry["params"] = channel_class.Params.model_json_schema()
|
||||
if channel_class.ClientMessage is not None:
|
||||
entry["client_message"] = channel_class.ClientMessage.model_json_schema()
|
||||
entry["bidirectional"] = True
|
||||
if channel_class.ServerMessage is not None:
|
||||
entry["server_message"] = channel_class.ServerMessage.model_json_schema()
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
_registry.clear()
|
||||
|
||||
|
||||
register_extension("channels", _ChannelsExtension())
|
||||
|
||||
@@ -6,8 +6,7 @@ Usage:
|
||||
|
||||
Imports the named module (whose import side effects must register every
|
||||
@client function with `mizan_core.registry`), then writes the canonical
|
||||
Mizan IR as KDL to stdout. The Rust codegen binary consumes this
|
||||
directly.
|
||||
Mizan IR as KDL to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"""
|
||||
FastAPI router exposing Mizan's HTTP endpoints:
|
||||
|
||||
GET /session/ — session-init probe
|
||||
POST /call/ — RPC dispatch
|
||||
GET /ctx/{context_name}/ — bundled context fetch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from mizan_fastapi import router, mizan_exception_handler, MizanError
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/mizan")
|
||||
app.add_exception_handler(MizanError, mizan_exception_handler)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +17,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
from .executor import (
|
||||
from mizan_fastapi.executor import (
|
||||
ErrorCode,
|
||||
MizanError,
|
||||
NotFound,
|
||||
@@ -45,12 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
|
||||
|
||||
@router.get("/session/")
|
||||
async def session_init() -> JSONResponse:
|
||||
"""Session-init probe. Parity with mizan-django's session endpoint.
|
||||
|
||||
CSRF is a Django-only concern at the protocol level; FastAPI surfaces a
|
||||
null token so the response shape stays uniform across backends. The
|
||||
wire-parity harness uses this endpoint as its readiness probe.
|
||||
"""
|
||||
"""Session-init probe. The CSRF slot is null — nothing on this backend issues a token."""
|
||||
return _no_store({"csrfToken": None})
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,8 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
|
||||
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
|
||||
{"error": "..."}
|
||||
|
||||
The envelope is mizan-django's, so a client speaks to either backend unchanged. RPC
|
||||
dispatches through the same `execute_function` the HTTP route calls — one dispatch path,
|
||||
so the two transports cannot disagree about what a function does.
|
||||
|
||||
`data` is the `{result, invalidate, merge}` envelope every non-HTTP transport hands the
|
||||
kernel, so `mizanCall` applies a socket mutation's invalidation exactly as it applies an
|
||||
HTTP one. mizan-django's socket sends a bare result and drops it.
|
||||
An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the
|
||||
HTTP route builds, and both are produced by `execute_function`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,9 +42,9 @@ ws_router = APIRouter()
|
||||
class _SocketRequest:
|
||||
"""What a server function receives when the call arrived over the socket.
|
||||
|
||||
The HTTP path hands functions a Starlette `Request`; there is none here, so this
|
||||
carries the surface a function actually reads — `state`, headers, and a method, since
|
||||
a socket RPC sends data and expects an answer.
|
||||
There is no Starlette `Request` on a socket, so this carries the surface a function
|
||||
actually reads — `state`, headers, and a method, since a socket RPC sends data and
|
||||
expects an answer.
|
||||
"""
|
||||
|
||||
method = "POST"
|
||||
@@ -64,7 +59,7 @@ class _SocketRequest:
|
||||
|
||||
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any:
|
||||
"""Params as the channel's declared model, or a bare holder when it declares none."""
|
||||
model = getattr(channel_cls, "Params", None)
|
||||
model = channel_cls.Params
|
||||
if model is not None and raw:
|
||||
return model(**raw)
|
||||
return channels._Params(raw) if raw else None
|
||||
@@ -203,10 +198,10 @@ _ACTIONS = {
|
||||
|
||||
@ws_router.websocket("/ws/")
|
||||
async def mizan_socket(socket: WebSocket) -> None:
|
||||
"""One connection, every action. A disconnect ends the loop and clears the membership.
|
||||
"""One connection, every action.
|
||||
|
||||
A close is how a socket ends, so the disconnect is reported as the departure it is
|
||||
rather than raised as a fault; `finally` clears the membership either way.
|
||||
A close is how a socket ends, so the disconnect is logged rather than raised;
|
||||
`finally` clears the membership either way.
|
||||
"""
|
||||
await socket.accept()
|
||||
try:
|
||||
|
||||
@@ -88,7 +88,7 @@ def app():
|
||||
|
||||
@client
|
||||
async def async_echo(request, text: str) -> EchoOutput:
|
||||
# await something on the loop to prove we're really running async
|
||||
# Yielding to the loop fails outright if the handler is not awaited.
|
||||
await asyncio.sleep(0)
|
||||
return EchoOutput(message=f"async: {text}")
|
||||
|
||||
@@ -183,18 +183,19 @@ class ContextFetchTests:
|
||||
assert r.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
# ─── Auth gating ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuthTests:
|
||||
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
|
||||
|
||||
def test_anonymous_request_to_auth_required_returns_401(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
|
||||
assert r.status_code == 401
|
||||
assert r.json()["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InvalidationTests:
|
||||
def test_mutation_emits_invalidate_list(self, http):
|
||||
r = http.post(
|
||||
@@ -211,8 +212,6 @@ class InvalidationTests:
|
||||
|
||||
|
||||
class StructuredOutputTests:
|
||||
"""list[BaseModel] and Optional[BaseModel] should reach the wire as bare values, not {result: ...}."""
|
||||
|
||||
def test_list_of_basemodel_returns_bare_array(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
|
||||
assert r.status_code == 200
|
||||
@@ -232,21 +231,20 @@ class StructuredOutputTests:
|
||||
assert r_missing.json()["result"] is None
|
||||
|
||||
|
||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
# ─── Async handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AsyncHandlerTests:
|
||||
"""`async def` handlers dispatch on the loop via view.acall."""
|
||||
|
||||
def test_async_handler_returns_awaited_result(self, http):
|
||||
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"] == {"message": "async: hello"}
|
||||
|
||||
|
||||
class MergeTests:
|
||||
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
|
||||
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MergeTests:
|
||||
def test_merge_target_emits_merge_entry(self, http):
|
||||
r = http.post(
|
||||
"/api/mizan/call/",
|
||||
@@ -254,9 +252,8 @@ class MergeTests:
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Server resolves slot — items_list returns list[ItemOutput], mutation returns ItemOutput
|
||||
# items_list returns list[ItemOutput], so the slot resolves to items_list.
|
||||
assert body["merge"] == [
|
||||
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
|
||||
]
|
||||
# invalidate stays empty when only merge is declared
|
||||
assert body["invalidate"] == []
|
||||
|
||||
17
backends/mizan-rust-axum/Cargo.lock
generated
17
backends/mizan-rust-axum/Cargo.lock
generated
@@ -264,12 +264,28 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
@@ -300,6 +316,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's
|
||||
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}`
|
||||
//! with a Cache-Control: no-store header.
|
||||
//! Render a `MizanError` as an axum `Response`: the JSON envelope
|
||||
//! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
|
||||
//! `Cache-Control: no-store` header.
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -15,11 +15,24 @@ impl From<MizanError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each variant's status spelled as an axum constant. Naming the constant
|
||||
/// rather than round-tripping a `u16` leaves no numeric value axum could
|
||||
/// reject, so the mapping is total.
|
||||
fn status_of(err: &MizanError) -> StatusCode {
|
||||
match err {
|
||||
MizanError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
MizanError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
MizanError::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
MizanError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
MizanError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
MizanError::NotImplementedYet(_) => StatusCode::NOT_IMPLEMENTED,
|
||||
MizanError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = StatusCode::from_u16(self.0.http_status())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut resp = (status, Json(self.0.to_json())).into_response();
|
||||
let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
|
||||
//! HTTP handlers for the Mizan endpoints.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
|
||||
/// `Arc` keeps the clone cheap across per-request handler invocations.
|
||||
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Body for POST /call/. Matches the Python `CallBody` shape.
|
||||
/// Body for POST /call/.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallBody {
|
||||
pub fn_: Option<String>,
|
||||
/// `fn` is a Rust keyword, hence the serde rename.
|
||||
#[serde(rename = "fn")]
|
||||
pub function_name: Option<String>,
|
||||
pub function_name: String,
|
||||
#[serde(default)]
|
||||
pub args: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl CallBody {
|
||||
fn resolved_name(&self) -> Option<&str> {
|
||||
self.function_name
|
||||
.as_deref()
|
||||
.or(self.fn_.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CallResponse {
|
||||
pub result: Value,
|
||||
@@ -47,28 +39,37 @@ pub struct CallResponse {
|
||||
pub merge: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn no_store(json: Value) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(json)).into_response();
|
||||
fn no_store<T: Serialize>(body: T) -> Response {
|
||||
let mut resp = (StatusCode::OK, Json(body)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
resp
|
||||
}
|
||||
|
||||
/// POST /call/ — RPC dispatch.
|
||||
/// POST /call/ — RPC dispatch. The caller picks the `fn` string, so the
|
||||
/// handler selects the registrations that string names and matches over the
|
||||
/// two shapes that selection has; `[]` is the selection a string nothing
|
||||
/// registered under makes, and it is answered with the NOT_FOUND envelope.
|
||||
pub async fn function_call(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Json(body): Json<CallBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let fn_name = body
|
||||
.resolved_name()
|
||||
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
|
||||
.to_string();
|
||||
|
||||
let fn_spec = lookup_function(&fn_name)
|
||||
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
|
||||
let registered = function_named(&body.function_name);
|
||||
let fn_spec = match registered.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"function {:?} not registered",
|
||||
body.function_name
|
||||
))))
|
||||
}
|
||||
[fn_spec, ..] => *fn_spec,
|
||||
};
|
||||
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
|
||||
let result = match fn_spec.dispatch(req, Value::Object(body.args.clone())).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
};
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
|
||||
.iter()
|
||||
@@ -81,82 +82,86 @@ pub async fn function_call(
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
|
||||
let payload = CallResponse {
|
||||
Ok(no_store(CallResponse {
|
||||
result,
|
||||
invalidate,
|
||||
merge: merge_payload,
|
||||
};
|
||||
Ok(no_store(serde_json::to_value(&payload).unwrap()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /ctx/:context_name/ — bundled context fetch.
|
||||
/// GET /ctx/:context_name/ — bundled context fetch. The caller picks the
|
||||
/// path segment, so `[]` is the selection a segment no registered function
|
||||
/// declares membership in makes, answered with the NOT_FOUND envelope.
|
||||
pub async fn context_fetch(
|
||||
State(app_state): State<AppStateAny>,
|
||||
Path(context_name): Path<String>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if lookup_context(&context_name).is_none() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
let members = context_members(&context_name);
|
||||
let selected = match members.as_slice() {
|
||||
[] => {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} names no registered functions"
|
||||
))))
|
||||
}
|
||||
selected => selected,
|
||||
};
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(&context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ApiError(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
// Convert query params (all-string values) to the JSON arg map. Numeric
|
||||
// params get parsed via the per-function input_params primitive table.
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
for fn_spec in selected {
|
||||
let args = coerce_query_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::from_dyn(app_state.as_ref());
|
||||
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||
Ok(result) => {
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Err(e) => return Err(ApiError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(no_store(Value::Object(bundled)))
|
||||
}
|
||||
|
||||
/// Coerce string-valued query params into typed JSON values using the
|
||||
/// function's declared input_params. Strings that don't parse stay as
|
||||
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
|
||||
/// A query string carries every value as text, so each declared input param
|
||||
/// reads its raw text as the primitive it declares. Text spelling something
|
||||
/// else stays the text it already is: `dispatch` validates every arg against
|
||||
/// the declared shape and is the one step that words the VALIDATION_FAILED
|
||||
/// answer, so re-wording it here would give one request two spellings of the
|
||||
/// same complaint.
|
||||
fn coerce_query_args(
|
||||
fn_spec: &dyn FunctionSpec,
|
||||
params: &BTreeMap<String, String>,
|
||||
) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(raw) = params.get(ip.name) {
|
||||
let parsed = match ip.primitive {
|
||||
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
|
||||
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
|
||||
serde_json::Number::from_f64(v).map(Value::Number)
|
||||
}),
|
||||
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
|
||||
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
|
||||
for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
|
||||
let as_text = Value::from(raw.clone());
|
||||
let coerced = match ip.primitive {
|
||||
Primitive::String => as_text,
|
||||
Primitive::Boolean => match raw.as_str() {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
_spells_neither => as_text,
|
||||
},
|
||||
Primitive::Integer => match raw.parse::<i64>() {
|
||||
Ok(integer) => Value::from(integer),
|
||||
Err(_spells_no_integer) => as_text,
|
||||
},
|
||||
Primitive::Number => match raw.parse::<f64>() {
|
||||
Ok(float) => match Number::from_f64(float) {
|
||||
Some(number) => Value::Number(number),
|
||||
None => as_text,
|
||||
},
|
||||
Err(_spells_no_number) => as_text,
|
||||
},
|
||||
};
|
||||
if let Some(v) = parsed {
|
||||
out.insert(ip.name.into(), v);
|
||||
} else {
|
||||
out.insert(ip.name.into(), Value::from(raw.clone()));
|
||||
}
|
||||
out.insert(ip.name.into(), coerced);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint.
|
||||
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
|
||||
/// readiness-probe consumers see a well-formed response.
|
||||
/// GET /session/ — emits `{"csrfToken": null}`.
|
||||
pub async fn session_init() -> Response {
|
||||
let body = serde_json::json!({ "csrfToken": null });
|
||||
no_store(body)
|
||||
no_store(serde_json::json!({ "csrfToken": null }))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
|
||||
//! * `GET /session/` — session-init probe (placeholder CSRF token)
|
||||
//! Exposed endpoints:
|
||||
//! * `GET /session/` — session-init probe
|
||||
//! * `POST /call/` — RPC dispatch with invalidate+merge response
|
||||
//! * `GET /ctx/:name/` — bundled context fetch
|
||||
|
||||
@@ -51,8 +51,7 @@ where
|
||||
}
|
||||
|
||||
/// Router variant for callers that have no app state to thread — the
|
||||
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
|
||||
/// and other stateless test apps.
|
||||
/// dispatch path receives a unit-typed handle.
|
||||
pub fn router_stateless() -> Router {
|
||||
router(())
|
||||
}
|
||||
|
||||
17
backends/mizan-tauri/Cargo.lock
generated
17
backends/mizan-tauri/Cargo.lock
generated
@@ -1747,6 +1747,12 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -1762,6 +1768,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1789,6 +1805,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"linkme",
|
||||
"minijinja",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
|
||||
//!
|
||||
//! Ships as a Tauri plugin. The consumer installs it with one line:
|
||||
//! Ships as a Tauri plugin:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tauri::Builder::default()
|
||||
//! .plugin(mizan_tauri::init())
|
||||
//! .run(tauri::generate_context!())
|
||||
//! .expect("error while running tauri application");
|
||||
//! ```
|
||||
//!
|
||||
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
|
||||
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
|
||||
//! sends call/fetch envelopes to it; the dispatch routes through
|
||||
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
|
||||
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
|
||||
//! consumes. There is no per-function tauri::command; the registry IS
|
||||
//! the dispatch table.
|
||||
//! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
|
||||
//! FUNCTIONS / CONTEXTS registries. There is no per-function
|
||||
//! `tauri::command`; the registry IS the dispatch table.
|
||||
//!
|
||||
//! Wire envelope:
|
||||
//!
|
||||
@@ -24,23 +20,20 @@
|
||||
//! { "op": "fetch", "context": "session", "params": {} }
|
||||
//! ```
|
||||
//!
|
||||
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
|
||||
//! mizan-rust-axum:
|
||||
//! Response shapes:
|
||||
//!
|
||||
//! * `call` → `{ result, invalidate, merge? }`
|
||||
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
|
||||
//!
|
||||
//! Error responses come back as the `Err` variant of the Tauri command's
|
||||
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
|
||||
//! The TS-side transport re-wraps it into a `MizanError` so consumers
|
||||
//! see one error surface regardless of transport.
|
||||
//! Errors come back as the `Err` variant of the command's `Result`, which
|
||||
//! Tauri serializes into the JS-side `Promise.reject`.
|
||||
|
||||
use mizan_core::{
|
||||
compute_invalidation, compute_merges, lookup_context, lookup_function,
|
||||
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
|
||||
compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
|
||||
InvalidationTarget, MergeEntry, MizanError, RequestHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
@@ -79,9 +72,8 @@ pub enum Envelope {
|
||||
},
|
||||
}
|
||||
|
||||
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
|
||||
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
|
||||
/// this and constructs a `MizanError`.
|
||||
/// Error payload returned to the frontend. The JS-side transport reads
|
||||
/// `code` / `message` / `details` and constructs a `MizanError`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: &'static str,
|
||||
@@ -114,6 +106,11 @@ impl From<MizanError> for ErrorPayload {
|
||||
/// it into a `RequestHandle` so `#[mizan::client]` functions can
|
||||
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
|
||||
/// emission. Stateless functions ignore the handle.
|
||||
///
|
||||
/// Each arm selects the registrations its envelope names and matches over
|
||||
/// the two shapes that selection has. Both shapes are ordinary: the JS side
|
||||
/// picks the string, so `[]` is the selection a string nothing registered
|
||||
/// under makes, and it is answered with the NOT_FOUND envelope.
|
||||
#[tauri::command]
|
||||
async fn mizan_invoke<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
@@ -123,98 +120,79 @@ async fn mizan_invoke<R: Runtime>(
|
||||
Envelope::Call {
|
||||
function_name,
|
||||
args,
|
||||
} => handle_call(&app, &function_name, args).await,
|
||||
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
|
||||
} => {
|
||||
let registered = function_named(&function_name);
|
||||
let fn_spec = match registered.as_slice() {
|
||||
[] => {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {function_name:?} not registered"
|
||||
))))
|
||||
}
|
||||
[fn_spec, ..] => *fn_spec,
|
||||
};
|
||||
|
||||
let req = RequestHandle::new(&app);
|
||||
match fn_spec.dispatch(req, Value::Object(args.clone())).await {
|
||||
Ok(result) => Ok(call_payload(fn_spec, &args, result)),
|
||||
Err(e) => Err(ErrorPayload::from(e)),
|
||||
}
|
||||
}
|
||||
Envelope::Fetch { context, params } => {
|
||||
let members = context_members(&context);
|
||||
let selected = match members.as_slice() {
|
||||
[] => {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context:?} names no registered functions"
|
||||
))))
|
||||
}
|
||||
selected => selected,
|
||||
};
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in selected {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(&app);
|
||||
match fn_spec.dispatch(req, Value::Object(args)).await {
|
||||
Ok(result) => {
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
Err(e) => return Err(ErrorPayload::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_call<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
fn_name: &str,
|
||||
args: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
|
||||
ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"function {fn_name:?} not registered"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args.clone()))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
|
||||
/// The `call` response body — the handler's result alongside the
|
||||
/// invalidation targets and merge entries the registry derives from the
|
||||
/// arguments and that result.
|
||||
fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
|
||||
let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
|
||||
.iter()
|
||||
.map(InvalidationTarget::to_json)
|
||||
.collect();
|
||||
let merges = compute_merges(fn_spec, &args, &result);
|
||||
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merges.iter().map(MergeEntry::to_json).collect())
|
||||
};
|
||||
let merges = compute_merges(fn_spec, args, &result);
|
||||
|
||||
let mut payload = json!({
|
||||
"result": result,
|
||||
"invalidate": invalidate,
|
||||
});
|
||||
if let Some(merge) = merge_payload {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.expect("payload is a JSON object")
|
||||
.insert("merge".into(), Value::Array(merge));
|
||||
let mut payload = Map::new();
|
||||
payload.insert("result".into(), result);
|
||||
payload.insert("invalidate".into(), Value::Array(invalidate));
|
||||
if !merges.is_empty() {
|
||||
let entries: Vec<Value> = merges.iter().map(MergeEntry::to_json).collect();
|
||||
payload.insert("merge".into(), Value::Array(entries));
|
||||
}
|
||||
Ok(payload)
|
||||
Value::Object(payload)
|
||||
}
|
||||
|
||||
async fn handle_fetch<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
context_name: &str,
|
||||
params: Map<String, Value>,
|
||||
) -> Result<Value, ErrorPayload> {
|
||||
if lookup_context(context_name).is_none() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} not registered"
|
||||
))));
|
||||
}
|
||||
|
||||
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.context() == Some(context_name))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(ErrorPayload::from(MizanError::NotFound(format!(
|
||||
"context {context_name:?} has no registered members"
|
||||
))));
|
||||
}
|
||||
|
||||
let mut bundled = Map::new();
|
||||
for fn_spec in &members {
|
||||
let args = filter_args(*fn_spec, ¶ms);
|
||||
let req = RequestHandle::new(app);
|
||||
let result = fn_spec
|
||||
.dispatch(req, Value::Object(args))
|
||||
.await
|
||||
.map_err(ErrorPayload::from)?;
|
||||
bundled.insert(fn_spec.name().to_string(), result);
|
||||
}
|
||||
|
||||
Ok(Value::Object(bundled))
|
||||
}
|
||||
|
||||
/// Filter the envelope's params down to keys this function declares as
|
||||
/// input. The HTTP/axum adapter coerces string-typed query params to
|
||||
/// JSON primitives in the equivalent step; the Tauri arg channel already
|
||||
/// carries typed JSON, so the filter is sufficient on its own.
|
||||
/// The envelope's params narrowed to the keys this function declares as
|
||||
/// input. The Tauri arg channel already carries typed JSON, so no
|
||||
/// string-to-primitive coercion is needed here.
|
||||
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for ip in fn_spec.input_params() {
|
||||
if let Some(v) = params.get(ip.name) {
|
||||
out.insert(ip.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
let declared = fn_spec.input_params();
|
||||
params
|
||||
.iter()
|
||||
.filter(|(name, _)| declared.iter().any(|ip| ip.name == name.as_str()))
|
||||
.map(|(name, value)| (name.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
7
backends/mizan-ts/src/cache/backend.ts
vendored
7
backends/mizan-ts/src/cache/backend.ts
vendored
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Cache backends — MemoryCache for testing.
|
||||
* A cache backend is a flat string-to-string store.
|
||||
*
|
||||
* Simple key-value store. No reverse indexes.
|
||||
* There is no reverse index from a context to its keys, so `deleteByPrefix` is
|
||||
* what a broad purge relies on and every backend owes it.
|
||||
*/
|
||||
|
||||
export interface CacheBackend {
|
||||
get(key: string): string | null
|
||||
set(key: string, value: string): void
|
||||
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
|
||||
|
||||
deleteByPrefix(prefix: string): number {
|
||||
let count = 0
|
||||
// Snapshot the keys — deleting while iterating the live view is UB.
|
||||
for (const key of [...this._store.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._store.delete(key)
|
||||
|
||||
17
backends/mizan-ts/src/cache/index.ts
vendored
17
backends/mizan-ts/src/cache/index.ts
vendored
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* mizan cache — TypeScript adapter.
|
||||
*
|
||||
* Same protocol as Python's mizan.cache. Cross-language conformance
|
||||
* verified by pin tests. No reverse indexes — scoped purge recomputes
|
||||
* the key directly, broad purge uses prefix scan.
|
||||
*/
|
||||
|
||||
export { MemoryCache } from './backend'
|
||||
export type { CacheBackend } from './backend'
|
||||
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
|
||||
@@ -52,6 +44,13 @@ export function cachePut(
|
||||
backend.set(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached entries for a context. Returns the number removed.
|
||||
*
|
||||
* With params and a secret the exact key is recomputed and dropped; without
|
||||
* them every key carrying the context prefix is scanned and dropped. There is
|
||||
* no reverse index from a context to its keys, so those are the only two forms.
|
||||
*/
|
||||
export function cachePurge(
|
||||
backend: CacheBackend,
|
||||
context: string,
|
||||
@@ -61,11 +60,9 @@ export function cachePurge(
|
||||
rev: number = 0,
|
||||
): number {
|
||||
if (params && secret) {
|
||||
// Scoped purge — recompute key and delete directly
|
||||
const key = deriveCacheKey(secret, context, params, userId, rev)
|
||||
return backend.delete(key) ? 1 : 0
|
||||
} else {
|
||||
// Broad purge — prefix scan
|
||||
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
|
||||
return backend.deleteByPrefix(prefix)
|
||||
}
|
||||
|
||||
14
backends/mizan-ts/src/cache/keys.ts
vendored
14
backends/mizan-ts/src/cache/keys.ts
vendored
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
|
||||
* Cache key derivation — HMAC-SHA256 over a JSON-canonical form.
|
||||
*
|
||||
* Protocol-critical: must produce identical output to Python's derive_cache_key.
|
||||
* Cross-language conformance verified by pin tests.
|
||||
*
|
||||
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
|
||||
* Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
|
||||
* broad purge run as a prefix scan over the backend's keyspace.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto'
|
||||
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
|
||||
|
||||
/**
|
||||
* JSON.stringify with recursively sorted keys and no whitespace.
|
||||
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
||||
*
|
||||
* Hand-rolled rather than JSON.stringify because the bytes must match
|
||||
* Python's json.dumps(obj, sort_keys=True, separators=(",", ":")) exactly —
|
||||
* a key derived here is looked up by the Python side under the same secret,
|
||||
* so any serialization drift silently splits the keyspace in two.
|
||||
*/
|
||||
function stableStringify(obj: any): string {
|
||||
if (obj === null || obj === undefined) return 'null'
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
/**
|
||||
* Mizan @client decorator and function wrapper.
|
||||
*
|
||||
* Two registration styles:
|
||||
*
|
||||
* 1. Function wrapper (standalone functions):
|
||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
||||
*
|
||||
* 2. Class decorator (methods):
|
||||
* class Handlers {
|
||||
* @client({ context: UserCtx })
|
||||
* async userProfile(userId: number) { ... }
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||
import { register } from './registry'
|
||||
|
||||
@@ -35,8 +20,14 @@ function normalizeAffects(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover parameter names by parsing the function's own source text.
|
||||
*
|
||||
* Names are the wire contract — the dispatcher matches request params to
|
||||
* positional arguments by name — and JS erases them at runtime, so the
|
||||
* source string is the only place they survive.
|
||||
*/
|
||||
function extractParams(fn: Function): ParamDef[] {
|
||||
// Extract parameter names from function.toString()
|
||||
const source = fn.toString()
|
||||
const match = source.match(/\(([^)]*)\)/)
|
||||
if (!match || !match[1].trim()) return []
|
||||
@@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] {
|
||||
.map(p => p.trim())
|
||||
.filter(p => p && !p.startsWith('...'))
|
||||
.map(p => {
|
||||
// Handle destructured defaults: name = default, name: type
|
||||
// Strips a default value or a type annotation off the name
|
||||
const name = p.split(/[=:]/)[0].trim()
|
||||
return { name, type: 'any', required: !p.includes('=') }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function wrapper — registers a standalone function.
|
||||
*
|
||||
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
|
||||
*/
|
||||
/** Wrap and register a standalone function. */
|
||||
export function client<T extends (...args: any[]) => Promise<any>>(
|
||||
options: ClientOptions,
|
||||
fn: T,
|
||||
): T
|
||||
|
||||
/**
|
||||
* Class method decorator.
|
||||
*
|
||||
* class Handlers {
|
||||
* @client({ context: UserCtx })
|
||||
* async userProfile(userId: number) { ... }
|
||||
* }
|
||||
*/
|
||||
/** Register a class method. */
|
||||
export function client(options: ClientOptions): MethodDecorator
|
||||
|
||||
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
|
||||
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
|
||||
// Function wrapper form: client(options, fn)
|
||||
if (fn && typeof fn === 'function') {
|
||||
const options = optionsOrFn as ClientOptions
|
||||
@@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
|
||||
const name = fn.name || 'anonymous'
|
||||
const params = extractParams(fn)
|
||||
const isView = false // Determined at call time for function wrappers
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
name,
|
||||
fn: fn as any,
|
||||
fn: fn as RegistryEntry['fn'],
|
||||
context,
|
||||
affects,
|
||||
params,
|
||||
private: options.private ?? false,
|
||||
viewPath: isView,
|
||||
// A wrapped function's return is only known once called, so the
|
||||
// view-vs-RPC split is decided by the dispatcher, not here.
|
||||
viewPath: false,
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Request dispatch — context GET and mutation POST handlers.
|
||||
* Context GET and mutation POST handlers.
|
||||
*
|
||||
* Framework-agnostic. Returns plain objects. The router adapter
|
||||
* (Express, Hono, etc.) converts to framework-specific responses.
|
||||
* Handlers return plain MizanResponse objects; turning one into a
|
||||
* framework's own response type is the router adapter's job.
|
||||
*/
|
||||
|
||||
import { getFunction, getContextGroups } from './registry'
|
||||
@@ -62,7 +62,10 @@ export async function handleContextFetch(
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss on error */ }
|
||||
} catch (e: any) {
|
||||
// A failed lookup degrades to a miss, so recompute below rather than fail the request.
|
||||
console.error(`mizan: cache lookup failed for context '${contextName}'`, e)
|
||||
}
|
||||
}
|
||||
|
||||
const results: Record<string, any> = {}
|
||||
@@ -86,6 +89,7 @@ export async function handleContextFetch(
|
||||
|
||||
results[fnName] = result
|
||||
} catch (e: any) {
|
||||
console.error(`mizan: context function '${fnName}' raised`, e)
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||
@@ -111,7 +115,10 @@ export async function handleContextFetch(
|
||||
if (cacheBackend && cacheSecret && effectiveCache !== false) {
|
||||
try {
|
||||
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
|
||||
} catch { /* cache store failure is non-fatal */ }
|
||||
} catch (e: any) {
|
||||
// The results are already computed, so a store failure costs a future hit, not this response.
|
||||
console.error(`mizan: cache store failed for context '${contextName}'`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -186,20 +193,25 @@ export async function handleMutationCall(
|
||||
// Purge origin-side cache
|
||||
const cb = getCache()
|
||||
if (cb) {
|
||||
try {
|
||||
for (const entry of invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
cachePurge(cb, entry)
|
||||
for (const target of invalidate) {
|
||||
try {
|
||||
if (typeof target === 'string') {
|
||||
cachePurge(cb, target)
|
||||
} else {
|
||||
cachePurge(cb, entry.context, entry.params, _cacheSecret)
|
||||
cachePurge(cb, target.context, target.params, _cacheSecret)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// The client still gets X-Mizan-Invalidate, so a stale origin entry
|
||||
// is recoverable; one bad target must not skip the remaining ones.
|
||||
console.error(`mizan: cache purge failed for`, target, e)
|
||||
}
|
||||
} catch { /* purge failure is non-fatal */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, body: responseData, headers }
|
||||
} catch (e: any) {
|
||||
console.error(`mizan: mutation '${fnName}' raised`, e)
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
/**
|
||||
* Invalidation protocol — header formatting, auto-scoping.
|
||||
*
|
||||
* Matches Django's implementation exactly. Same format. Same rules.
|
||||
*/
|
||||
|
||||
import type { RegistryEntry } from './types'
|
||||
import { getContextGroups, getContextParamNames, getFunction } from './registry'
|
||||
import { getContextGroups, getContextParamNames } from './registry'
|
||||
|
||||
type InvalidateEntry = string | { context: string; params: Record<string, any> }
|
||||
|
||||
/**
|
||||
* Resolve invalidation targets with three-tier auto-scoping.
|
||||
* Resolve what a mutation's `affects` targets invalidate.
|
||||
*
|
||||
* Tier 1: Argument name matching
|
||||
* Tier 2: Auth inference (Edge-side, not handled here)
|
||||
* Tier 3: Broad fallback
|
||||
* Each target narrows to the call arguments whose names the target's context
|
||||
* also declares as params — that intersection is the scoped purge. A target
|
||||
* with no such overlap emits as a bare context name, meaning purge every
|
||||
* entry under that context.
|
||||
*/
|
||||
export function resolveInvalidation(
|
||||
entry: RegistryEntry,
|
||||
@@ -34,7 +29,6 @@ export function resolveInvalidation(
|
||||
const resolved = resolveAffectsTarget(targetName)
|
||||
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
|
||||
|
||||
// Tier 1: argument name matching
|
||||
if (callArgs && ctxForParams) {
|
||||
const contextParams = getContextParamNames(ctxForParams)
|
||||
const matched: Record<string, any> = {}
|
||||
@@ -47,7 +41,6 @@ export function resolveInvalidation(
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: broad fallback
|
||||
result.push(targetName)
|
||||
}
|
||||
|
||||
@@ -55,7 +48,10 @@ export function resolveInvalidation(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an affects target is a context name or function name.
|
||||
* Determine whether an affects target names a context or a function.
|
||||
*
|
||||
* An unrecognized name resolves as a context, so a target registered later
|
||||
* still purges by name rather than being dropped here.
|
||||
*/
|
||||
function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
|
||||
const groups = getContextGroups()
|
||||
@@ -74,9 +70,10 @@ function resolveAffectsTarget(name: string): { type: 'context' | 'function'; nam
|
||||
}
|
||||
|
||||
/**
|
||||
* Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
* Format invalidation targets as an X-Mizan-Invalidate header value.
|
||||
*
|
||||
* Format: comma-separated contexts. Semicolon-separated URL-encoded params.
|
||||
* Comma-separated targets; within a target, semicolon-separated URL-encoded
|
||||
* params follow the context name.
|
||||
*/
|
||||
export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Edge Manifest Generator
|
||||
*
|
||||
* Produces the same JSON format as mizan-django. One Edge Worker.
|
||||
* Two backend languages. Same manifest.
|
||||
*/
|
||||
|
||||
import type { EdgeManifest } from './types'
|
||||
import type { EdgeManifest, ManifestFunction } from './types'
|
||||
import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
|
||||
|
||||
// Both camelCase and snake_case forms included for cross-language matching.
|
||||
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention.
|
||||
// Wire format is snake_case; camelCase is the TS-local convention. Both forms
|
||||
// are listed because a param name arrives here as whichever the author wrote.
|
||||
const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
|
||||
|
||||
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
@@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
// Contexts
|
||||
for (const [ctxName, fnNames] of Object.entries(groups)) {
|
||||
const paramNames = new Set<string>()
|
||||
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = []
|
||||
const functions: ManifestFunction[] = []
|
||||
const pageRoutes: string[] = []
|
||||
|
||||
for (const fnName of fnNames) {
|
||||
@@ -29,14 +22,14 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
|
||||
|
||||
for (const p of entry.params) paramNames.add(p.name)
|
||||
|
||||
const fnEntry: any = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||
const fnEntry: ManifestFunction = { name: fnName, path: entry.viewPath ? 'view' : 'rpc' }
|
||||
if (entry.route) {
|
||||
fnEntry.route = entry.route
|
||||
fnEntry.methods = entry.methods || ['GET']
|
||||
pageRoutes.push(entry.route)
|
||||
}
|
||||
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
|
||||
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
|
||||
if (entry.cache !== undefined) fnEntry.cache = entry.cache
|
||||
functions.push(fnEntry)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Mizan Registry — Central registration for server functions.
|
||||
*/
|
||||
|
||||
import type { RegistryEntry } from './types'
|
||||
|
||||
const _functions: Map<string, RegistryEntry> = new Map()
|
||||
|
||||
export function register(entry: RegistryEntry): void {
|
||||
// Re-registering the same function object is a module re-evaluation, not a
|
||||
// name collision, so only a different fn under a taken name is an error.
|
||||
if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
|
||||
throw new Error(`Function '${entry.name}' already registered`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Mizan TypeScript Adapter — Shared Types
|
||||
*/
|
||||
|
||||
export class ReactContext {
|
||||
constructor(public readonly name: string) {
|
||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||
@@ -42,8 +38,17 @@ export interface RegistryEntry {
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ManifestFunction {
|
||||
name: string
|
||||
path: 'rpc' | 'view'
|
||||
route?: string
|
||||
methods?: string[]
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
export interface ManifestContext {
|
||||
functions: Array<{ name: string; path: 'rpc' | 'view' }>
|
||||
functions: ManifestFunction[]
|
||||
endpoints: string[]
|
||||
params: string[]
|
||||
user_scoped: boolean
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
/**
|
||||
* Edge Compatibility Tests — mirrors Django's EdgeCompatibilityTests exactly.
|
||||
*
|
||||
* These prove that a Cloudflare Worker (Edge) can sit in front of a
|
||||
* TypeScript backend and behave identically to sitting in front of Django.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
function setupUserContext() {
|
||||
const userProfile = client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||
client({ context: UserCtx }, async function userProfile(userId: number) {
|
||||
return { name: `user_${userId}`, email: `user${userId}@test.com` }
|
||||
})
|
||||
|
||||
const userOrders = client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||
client({ context: UserCtx }, async function userOrders(userId: number) {
|
||||
return { count: userId * 10 }
|
||||
})
|
||||
|
||||
const updateProfile = client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||
client({ affects: UserCtx }, async function updateProfile(userId: number, name: string) {
|
||||
return { name, email: `user${userId}@test.com` }
|
||||
})
|
||||
|
||||
@@ -122,7 +115,7 @@ describe('Edge Compatibility', () => {
|
||||
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } },
|
||||
])
|
||||
|
||||
// Parse (what Edge does)
|
||||
// Parse the header the way the Edge worker does
|
||||
const segments = header.split(';')
|
||||
const ctx = segments[0]
|
||||
const params: Record<string, string> = {}
|
||||
@@ -139,12 +132,6 @@ describe('Edge Compatibility', () => {
|
||||
// ── Empty invalidation ─────────────────────────────────────────────
|
||||
|
||||
test('no affects = no header, no body key', async () => {
|
||||
client({ context: new ReactContext('plain') }, async function plainFn() {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// A context function called via mutation dispatch (shouldn't have invalidation)
|
||||
// Actually test a function without affects
|
||||
clearRegistry()
|
||||
client({}, async function noAffects() { return { ok: true } })
|
||||
const r = await handleMutationCall('noAffects', {})
|
||||
@@ -251,6 +238,18 @@ describe('Manifest', () => {
|
||||
expect(fn.cache).toBe(60)
|
||||
})
|
||||
|
||||
test('cache=false appears in manifest', () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('nocache')
|
||||
client({ context: Ctx, cache: false }, async function uncachedFn() {
|
||||
return { value: 1 }
|
||||
})
|
||||
|
||||
const m = generateManifest()
|
||||
const fn = m.contexts.nocache.functions[0]
|
||||
expect(fn.cache).toBe(false)
|
||||
})
|
||||
|
||||
test('cache=60 still emits no-store on HTTP', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('live')
|
||||
@@ -294,8 +293,9 @@ describe('Cache Conformance', () => {
|
||||
})
|
||||
|
||||
test('deriveCacheKey cross-language pin (matches Python)', () => {
|
||||
// These exact values are pinned from Python's derive_cache_key output.
|
||||
// If this test fails, cross-language cache key compatibility is broken.
|
||||
// Literals captured from Python's derive_cache_key under the same
|
||||
// secret. A key derived here is looked up by the Python side, so a
|
||||
// mismatch means the two runtimes address different keyspaces.
|
||||
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
|
||||
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
|
||||
|
||||
@@ -362,6 +362,27 @@ describe('Cache Conformance', () => {
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('cache=false context is never stored', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('volatile')
|
||||
client({ context: Ctx, cache: false }, async function volatileFn(itemId: number) {
|
||||
return { value: itemId }
|
||||
})
|
||||
|
||||
const cache = new MemoryCache()
|
||||
setCache(cache)
|
||||
setCacheSecret(SECRET)
|
||||
|
||||
const r1 = await handleContextFetch('volatile', { itemId: '1' })
|
||||
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
const r2 = await handleContextFetch('volatile', { itemId: '1' })
|
||||
expect(r2.headers['X-Mizan-Cache']).toBe('MISS')
|
||||
|
||||
resetCache()
|
||||
setCacheSecret(null)
|
||||
})
|
||||
|
||||
test('handleMutationCall purges cache', async () => {
|
||||
clearRegistry()
|
||||
const Ctx = new ReactContext('product')
|
||||
|
||||
Reference in New Issue
Block a user