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:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

5
.gitignore vendored
View File

@@ -19,15 +19,14 @@ target/
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
examples/django-react-site/harness/test-results/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
# Build artifacts # Build artifacts
examples/django-react-desktop-app/frontend/dist/ protocol/mizan-generate/bin/mizan-generate-*
examples/django-react-site/harness/src/api/generated.*
examples/django-react-site/harness/test-results/
# Env # Env
.env .env

View File

@@ -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. `urls` and `Shape` resolve through `__getattr__` rather than at import time.
## 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 |
""" """
# All imports at module level (sorted) # All imports at module level (sorted)
from . import channels from mizan import channels
from . import client as client_module from mizan import client as client_module
from . import export from mizan import export
from . import forms from mizan import forms
from . import setup from mizan import setup
from .channels import ReactChannel from mizan.channels import Channel
from .channels import register as register_channel from mizan.channels import register as register_channel
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose from mizan.client import (
ComposedContext,
GlobalContext,
ReactContext,
ServerFunction,
client,
compose,
)
# Shape is lazy-loaded via __getattr__ because django_readers # Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate() # imports contenttypes, which can't happen during apps.populate()
from .setup import ( from mizan.setup import (
mizan_clients, mizan_clients,
mizan_module, mizan_module,
get_channel, get_channel,
@@ -105,11 +38,11 @@ from .setup import (
def __getattr__(name): def __getattr__(name):
"""Lazy loading for modules that can't be imported at app load time.""" """Lazy loading for modules that can't be imported at app load time."""
if name == "urls": if name == "urls":
from .urls import urlpatterns as mizan_patterns from mizan.urls import urlpatterns as mizan_patterns
return mizan_patterns return mizan_patterns
if name == "Shape": if name == "Shape":
from .shapes import Shape from mizan.shapes import Shape
return Shape return Shape
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -117,17 +50,8 @@ def __getattr__(name):
def wrap_asgi(http_application): def wrap_asgi(http_application):
""" """
Wrap an ASGI application with mizan WebSocket support. Route HTTP to `http_application` and /ws/ to the mizan consumer, with the
channels auth middleware supplying `scope["user"]` on the socket branch.
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
""" """
try: try:
from channels.auth import AuthMiddlewareStack from channels.auth import AuthMiddlewareStack
@@ -140,7 +64,7 @@ def wrap_asgi(http_application):
"Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS." "Add 'channels' to INSTALLED_APPS and configure CHANNEL_LAYERS."
) )
from .channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
return ProtocolTypeRouter( return ProtocolTypeRouter(
{ {
@@ -174,7 +98,7 @@ __all__ = [
# ASGI # ASGI
"wrap_asgi", "wrap_asgi",
# Channels # Channels
"ReactChannel", "Channel",
"register_channel", "register_channel",
# Shapes # Shapes
"Shape", "Shape",

View File

@@ -1,15 +1,20 @@
import inspect import inspect
import sys
from abc import ABC, abstractmethod
from importlib import import_module from importlib import import_module
from inspect import isclass from inspect import isclass
from typing import Protocol, Any from typing import Any
from django.conf import settings from django.conf import settings
def get_members(path): def get_members(path):
try: try:
module = import_module(path) module = import_module(path)
except ModuleNotFoundError: except ModuleNotFoundError as exc:
print('Could not import module "{}"'.format(path)) # 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 [] return []
members = [ members = [
@@ -21,7 +26,8 @@ def get_members(path):
return members return members
class DjangoAppVisitorHandler(Protocol): class DjangoAppVisitorHandler(ABC):
@abstractmethod
def on_module( def on_module(
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]] self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
) -> None: ... ) -> None: ...
@@ -29,13 +35,12 @@ class DjangoAppVisitorHandler(Protocol):
class DjangoAppVisitor: class DjangoAppVisitor:
""" """
Discovers Python modules under each Django app following conventions: Walks each installed app for modules named after `layer`:
- <app>/<module>.py -> url_prefix "<renamed>/" <app>/<layer>.py -> path_parts []
- <app>/<module>/**/*.py -> url_prefix "<renamed>/<subdirs...>/<module>/" <app>/<layer>/**/*.py -> path_parts [<subdirs...>, <stem>]
Example: `apps_root` is the dotted package the apps live under, relative to
<app>/<module>/forms/nksn.py -> url_prefix "<renamed>/forms/nksn/" BASE_DIR; "" means the apps sit directly at BASE_DIR.
module_path "<app>.module.forms.nksn"
""" """
def __init__( def __init__(
@@ -66,7 +71,6 @@ class DjangoAppVisitor:
app_module = f"{module_prefix}{app_name}" app_module = f"{module_prefix}{app_name}"
# 1) Visit package: <app>/<module>/**/*.py
layer_dir = app_dir / self.layer layer_dir = app_dir / self.layer
if layer_dir.is_dir(): if layer_dir.is_dir():
for py_file in layer_dir.rglob("*.py"): for py_file in layer_dir.rglob("*.py"):
@@ -83,7 +87,6 @@ class DjangoAppVisitor:
get_members(f"{app_module}.{self.layer}.{dotted}"), get_members(f"{app_module}.{self.layer}.{dotted}"),
) )
# 2) Visit module module file: <app>/module.py
layer_file = app_dir / f"{self.layer}.py" layer_file = app_dir / f"{self.layer}.py"
if layer_file.is_file(): if layer_file.is_file():
handler.on_module( handler.on_module(

View File

@@ -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. There are no reverse indexes: a scoped purge recomputes the one key it needs
Scoped purge recomputes the key and deletes directly. and deletes it, and a purge with no params falls back to a key-prefix scan.
Broad purge uses key-prefix scan (rare operation).
Usage:
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,7 +24,7 @@ _init_lock = threading.Lock()
def get_cache() -> CacheBackend | None: def get_cache() -> CacheBackend | None:
""" """
Get the configured cache backend, or None if caching is disabled. 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 global _cache_instance, _initialized
if _initialized: if _initialized:
@@ -43,6 +39,8 @@ def get_cache() -> CacheBackend | None:
from mizan.setup.settings import get_settings from mizan.setup.settings import get_settings
settings = 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: if settings.cache_secret and settings.cache_redis_url:
_cache_instance = RedisCache(settings.cache_redis_url) _cache_instance = RedisCache(settings.cache_redis_url)
logger.info("Mizan cache enabled (Redis: %s)", 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, rev: int = 0,
) -> int: ) -> 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 With params and a secret, the exact key is recomputed and deleted — one
it directly. One DELETE, no index needed. DELETE. Without them, every key under the prefix "ctx:{context}:" is
scanned and removed.
Broad purge (no params): scans by key prefix "ctx:{context}:*".
This is a rare operation (Tier 3 fallback in invalidation).
""" """
if params is not None and len(params) > 0 and secret: if params is not None and len(params) > 0 and secret:
key = derive_cache_key(secret, context, params, user_id, rev) key = derive_cache_key(secret, context, params, user_id, rev)

View File

@@ -1,81 +1,15 @@
""" """WebSocket channels: the Channel base class, the channel registry, and
mizan.channels - Real-time WebSocket communication. the schema exports built from it."""
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(...))
```
"""
import abc
import logging import logging
from typing import TYPE_CHECKING, Any, ClassVar, Type from typing import TYPE_CHECKING, Any, ClassVar, Type
from pydantic import BaseModel from pydantic import BaseModel
from mizan_core.ir import wire_to_pascal
from mizan_core.registry import RegistryExtension, register_extension
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from ninja import NinjaAPI from ninja import NinjaAPI
@@ -84,36 +18,25 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================= class Channel(abc.ABC):
# Base Classes
# =============================================================================
class ReactChannel:
""" """
Base class for WebSocket channels. A WebSocket channel.
Define nested Pydantic classes for typed messaging: Subclasses declare the wire types as nested Pydantic models:
- Params: Query parameters for subscribing (optional) Params (subscription query parameters), ClientMessage (travels
- ReactMessage: Messages from browser to server (optional) client -> server), ServerMessage (travels server -> client). Any
- DjangoMessage: Messages from server to browser (optional) slot left undeclared stays None and that direction is unavailable.
Implement required methods: authorize() and group() are abstract. receive(), on_connect() and
- authorize(): Permission check for connection on_disconnect() are the override points; each definition here records
- group(): Which group to broadcast to what happened and a subclass replaces or extends it.
Optionally implement:
- receive(): Handle incoming ReactMessage, return DjangoMessage to broadcast
- on_connect(): Called after successful connection
- on_disconnect(): Called when connection closes
""" """
# Nested classes (optional, defined by subclasses)
Params: ClassVar[Type[BaseModel] | None] = None Params: ClassVar[Type[BaseModel] | None] = None
ReactMessage: ClassVar[Type[BaseModel] | None] = None ClientMessage: ClassVar[Type[BaseModel] | None] = None
DjangoMessage: 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" user: "AbstractBaseUser | AnonymousUser"
_channel_layer: Any = None _channel_layer: Any = None
_channel_name: str = "" _channel_name: str = ""
@@ -125,64 +48,58 @@ class ReactChannel:
self._groups = set() self._groups = set()
self._params_dict = {} self._params_dict = {}
@abc.abstractmethod
def authorize(self, params: BaseModel | None = None) -> bool: def authorize(self, params: BaseModel | None = None) -> bool:
""" """Return True to allow the connection, False to reject it."""
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()"
)
@abc.abstractmethod
def group(self, params: BaseModel | None = None) -> str: def group(self, params: BaseModel | None = None) -> str:
""" """Return the channel-layer group name this subscription broadcasts to."""
Return the group name for broadcasting.
Messages returned from receive() are broadcast to this group.
"""
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None: def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
""" """
Handle incoming ReactMessage. Handle one ClientMessage; a returned ServerMessage is broadcast to the
group. A channel that accepts inbound frames overrides this — reaching
Return a DjangoMessage to broadcast to the group, or None to skip. the definition here means the frame has nowhere to go.
Override this to implement message handling.
""" """
logger.warning(
"%s does not handle inbound %s; the frame is dropped",
type(self).__name__,
type(msg).__name__,
)
return None return None
async def on_connect(self, params: BaseModel | None = None) -> None: async def on_connect(self, params: BaseModel | None = None) -> None:
"""Called after successful connection and group join.""" """Runs after the group join; a subclass extends it via super()."""
pass logger.debug(
"%s subscription opened on %s",
type(self).__name__,
self._channel_name or "<no channel name>",
)
async def on_disconnect(self) -> None: async def on_disconnect(self) -> None:
"""Called when the connection closes.""" """Runs as the subscription closes; a subclass extends it via super()."""
pass logger.debug(
"%s subscription closed, leaving %d group(s)",
# ------------------------------------------------------------------------- type(self).__name__,
# Internal Methods (used by the consumer) len(self._groups),
# ------------------------------------------------------------------------- )
async def _join_group(self, group_name: str) -> None: async def _join_group(self, group_name: str) -> None:
"""Join a channel layer group."""
if self._channel_layer: if self._channel_layer:
await self._channel_layer.group_add(group_name, self._channel_name) await self._channel_layer.group_add(group_name, self._channel_name)
self._groups.add(group_name) self._groups.add(group_name)
async def _leave_group(self, group_name: str) -> None: async def _leave_group(self, group_name: str) -> None:
"""Leave a channel layer group."""
if self._channel_layer and group_name in self._groups: if self._channel_layer and group_name in self._groups:
await self._channel_layer.group_discard(group_name, self._channel_name) await self._channel_layer.group_discard(group_name, self._channel_name)
self._groups.discard(group_name) self._groups.discard(group_name)
async def _leave_all_groups(self) -> None: async def _leave_all_groups(self) -> None:
"""Leave all joined groups."""
for group_name in list(self._groups): for group_name in list(self._groups):
await self._leave_group(group_name) await self._leave_group(group_name)
async def _broadcast(self, group_name: str, message: BaseModel) -> None: async def _broadcast(self, group_name: str, message: BaseModel) -> None:
"""Broadcast a message to a group."""
if self._channel_layer: if self._channel_layer:
await self._channel_layer.group_send( await self._channel_layer.group_send(
group_name, group_name,
@@ -195,20 +112,11 @@ class ReactChannel:
}, },
) )
# -------------------------------------------------------------------------
# Class Methods for Server Push
# -------------------------------------------------------------------------
@classmethod @classmethod
async def push(cls, message: BaseModel, **params) -> None: async def push(cls, message: BaseModel, **params) -> None:
""" """
Push a message from server code (views, tasks, signals). Send a ServerMessage to every subscriber of the group named by the
given params, from outside a subscription (views, tasks, signals).
Usage:
await ChatChannel.push(
room='general',
message=ChatChannel.DjangoMessage(user='system', text='Hello')
)
""" """
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@@ -219,16 +127,13 @@ class ReactChannel:
) )
return return
# Build params model if defined
params_obj = None params_obj = None
if cls.Params: if cls.Params:
params_obj = cls.Params(**params) params_obj = cls.Params(**params)
# Get group name
instance = cls() instance = cls()
group_name = instance.group(params_obj) group_name = instance.group(params_obj)
# Send to group
await channel_layer.group_send( await channel_layer.group_send(
group_name, group_name,
{ {
@@ -241,63 +146,31 @@ class ReactChannel:
) )
# ============================================================================= _registry: dict[str, Type[Channel]] = {}
# Registry
# =============================================================================
_registry: dict[str, Type[ReactChannel]] = {}
def register(channel_class: Type[ReactChannel], name: str) -> None: def register(channel_class: Type[Channel], name: str) -> None:
""" """Register a channel class under a URL-friendly wire name."""
Register a channel.
Args:
channel_class: The ReactChannel subclass to register
name: URL-friendly name (used in subscriptions)
"""
if name in _registry: if name in _registry:
raise ValueError(f"Channel '{name}' is already registered") raise ValueError(f"Channel '{name}' is already registered")
channel_class._registered_name = name 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 _registry[name] = channel_class
logger.debug(f"Registered channel: {name} -> {channel_class.__name__}") 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.""" """Get a registered channel class by name."""
return _registry.get(name) return _registry.get(name)
def get_registered_channels() -> dict[str, Type[ReactChannel]]: def get_registered_channels() -> dict[str, Type[Channel]]:
"""Get all registered channel classes.""" """Get a copy of the name -> channel-class registry."""
return dict(_registry) return dict(_registry)
# =============================================================================
# WebSocket Consumer
# =============================================================================
def get_websocket_application(): def get_websocket_application():
""" """Build the ASGI application that serves every registered channel."""
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(),
})
"""
try: try:
from channels.routing import URLRouter from channels.routing import URLRouter
from channels.auth import AuthMiddlewareStack from channels.auth import AuthMiddlewareStack
@@ -308,7 +181,7 @@ def get_websocket_application():
"Install it with: pip install channels channels-redis" "Install it with: pip install channels channels-redis"
) )
from .connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
return AuthMiddlewareStack( return AuthMiddlewareStack(
URLRouter( URLRouter(
@@ -319,42 +192,30 @@ def get_websocket_application():
) )
# =============================================================================
# Schema Export (for TypeScript generation)
# =============================================================================
def get_channels_schema() -> dict: def get_channels_schema() -> dict:
""" """JSON-schema per registered channel, keyed by wire name."""
Get schema for all registered channels (for TypeScript generation).
Returns a dict suitable for the frontend code generator.
"""
schema = {"channels": {}} schema = {"channels": {}}
for name, channel_class in _registry.items(): for name, channel_class in _registry.items():
channel_schema = { channel_schema = {
"name": name, "name": name,
"params": None, "params": None,
"reactMessage": None, "clientMessage": None,
"djangoMessage": None, "serverMessage": None,
} }
# Extract Params schema if channel_class.Params:
if hasattr(channel_class, "Params") and channel_class.Params:
channel_schema["params"] = channel_class.Params.model_json_schema() channel_schema["params"] = channel_class.Params.model_json_schema()
# Extract ReactMessage schema if channel_class.ClientMessage:
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema[ channel_schema[
"reactMessage" "clientMessage"
] = channel_class.ReactMessage.model_json_schema() ] = channel_class.ClientMessage.model_json_schema()
# Extract DjangoMessage schema if channel_class.ServerMessage:
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema[ channel_schema[
"djangoMessage" "serverMessage"
] = channel_class.DjangoMessage.model_json_schema() ] = channel_class.ServerMessage.model_json_schema()
schema["channels"][name] = channel_schema schema["channels"][name] = channel_schema
@@ -369,34 +230,37 @@ def _register_channel_schema_endpoint(
input_cls: type | None, input_cls: type | None,
output_cls: type, output_cls: type,
) -> None: ) -> 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: if input_cls is not None:
def endpoint(request, data): def schema_carrier(request, data):
pass return output_cls.model_json_schema()
endpoint.__annotations__ = {"data": input_cls} schema_carrier.__annotations__ = {"data": input_cls}
else: else:
def endpoint(request): def schema_carrier(request):
pass return output_cls.model_json_schema()
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)( api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
endpoint schema_carrier
) )
def get_channels_openapi_schema() -> dict: 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. Type names come from `mizan_core.ir.wire_to_pascal`, the same derivation
This schema is consumed by openapi-typescript for type generation. the Mizan IR emits, so the two documents name one type identically.
""" """
from ninja import NinjaAPI from ninja import NinjaAPI
from pydantic import BaseModel from pydantic import BaseModel
# Create temporary Ninja API for schema generation only
schema_api = NinjaAPI( schema_api = NinjaAPI(
title="mizan Channels", title="mizan Channels",
version="1.0.0", version="1.0.0",
@@ -405,29 +269,26 @@ def get_channels_openapi_schema() -> dict:
openapi_url=None, openapi_url=None,
) )
# Store dynamically created classes
schema_classes: dict[str, type] = {} schema_classes: dict[str, type] = {}
channel_metadata: list[dict] = [] channel_metadata: list[dict] = []
for name, channel_class in _registry.items(): for name, channel_class in _registry.items():
pascal_name = name.replace("_", " ").title().replace(" ", "") pascal_name = wire_to_pascal(name)
channel_meta = { channel_meta = {
"name": name, "name": name,
"pascalName": pascal_name, "pascalName": pascal_name,
"hasParams": False, "hasParams": False,
"hasReactMessage": False, "hasClientMessage": False,
"hasDjangoMessage": False, "hasServerMessage": False,
} }
# Register Params type if channel_class.Params:
if hasattr(channel_class, "Params") and channel_class.Params:
params_name = f"{pascal_name}Params" params_name = f"{pascal_name}Params"
schema_classes[params_name] = type(params_name, (channel_class.Params,), {}) schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
channel_meta["hasParams"] = True channel_meta["hasParams"] = True
channel_meta["paramsType"] = params_name channel_meta["paramsType"] = params_name
# Create dummy endpoint to include in schema
_register_channel_schema_endpoint( _register_channel_schema_endpoint(
api=schema_api, api=schema_api,
path=f"/channels/{name}/params", path=f"/channels/{name}/params",
@@ -437,63 +298,54 @@ def get_channels_openapi_schema() -> dict:
output_cls=BaseModel, output_cls=BaseModel,
) )
# Register ReactMessage type if channel_class.ClientMessage:
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage: client_name = f"{pascal_name}ClientMessage"
react_name = f"{pascal_name}ReactMessage" schema_classes[client_name] = type(
schema_classes[react_name] = type( client_name, (channel_class.ClientMessage,), {}
react_name, (channel_class.ReactMessage,), {}
) )
channel_meta["hasReactMessage"] = True channel_meta["hasClientMessage"] = True
channel_meta["reactMessageType"] = react_name channel_meta["clientMessageType"] = client_name
_register_channel_schema_endpoint( _register_channel_schema_endpoint(
api=schema_api, api=schema_api,
path=f"/channels/{name}/react", path=f"/channels/{name}/client",
operation_id=f"{name}ReactMessage", operation_id=f"{name}ClientMessage",
summary=f"{pascal_name} React→Django message", summary=f"{pascal_name} client→server message",
input_cls=schema_classes[react_name], input_cls=schema_classes[client_name],
output_cls=BaseModel, output_cls=BaseModel,
) )
# Register DjangoMessage type if channel_class.ServerMessage:
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage: server_name = f"{pascal_name}ServerMessage"
django_name = f"{pascal_name}DjangoMessage" schema_classes[server_name] = type(
schema_classes[django_name] = type( server_name, (channel_class.ServerMessage,), {}
django_name, (channel_class.DjangoMessage,), {}
) )
channel_meta["hasDjangoMessage"] = True channel_meta["hasServerMessage"] = True
channel_meta["djangoMessageType"] = django_name channel_meta["serverMessageType"] = server_name
_register_channel_schema_endpoint( _register_channel_schema_endpoint(
api=schema_api, api=schema_api,
path=f"/channels/{name}/django", path=f"/channels/{name}/server",
operation_id=f"{name}DjangoMessage", operation_id=f"{name}ServerMessage",
summary=f"{pascal_name} Django→React message", summary=f"{pascal_name} server→client message",
input_cls=None, input_cls=None,
output_cls=schema_classes[django_name], output_cls=schema_classes[server_name],
) )
channel_metadata.append(channel_meta) channel_metadata.append(channel_meta)
# Get OpenAPI schema from Ninja
# path_prefix="" avoids URL reverse() — this API is never mounted # path_prefix="" avoids URL reverse() — this API is never mounted
schema = schema_api.get_openapi_schema(path_prefix="") schema = schema_api.get_openapi_schema(path_prefix="")
# Add channel metadata extension
schema["x-mizan-channels"] = channel_metadata schema["x-mizan-channels"] = channel_metadata
return schema return schema
# =============================================================================
# Schema Endpoint (for TypeScript generation)
# =============================================================================
_schema_router = None _schema_router = None
def _get_schema_router(): def _get_schema_router():
"""Get the Ninja router for the channels schema endpoint."""
global _schema_router global _schema_router
if _schema_router is None: if _schema_router is None:
from ninja import Router from ninja import Router
@@ -502,17 +354,16 @@ def _get_schema_router():
@_schema_router.get("/schema/") @_schema_router.get("/schema/")
def channels_schema(request): def channels_schema(request):
"""Get schema for all registered channels (for TypeScript generation)."""
return get_channels_schema() return get_channels_schema()
return _schema_router return _schema_router
def get_urls(): def get_urls():
"""Get URL patterns for channels schema endpoint.""" """URL patterns serving the channels schema endpoint."""
from ninja import NinjaAPI from ninja import NinjaAPI
api = NinjaAPI(urls_namespace="django_react_channels") api = NinjaAPI(urls_namespace="mizan_channels")
api.add_router("/", _get_schema_router()) api.add_router("/", _get_schema_router())
return api.urls return api.urls
@@ -523,17 +374,8 @@ def __getattr__(name):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# ============================================================================= class _ChannelsExtension(RegistryExtension):
# Core Registry Extension """Exposes the channel registry to mizan_core under the 'channels' key."""
# =============================================================================
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.
"""
def all(self) -> dict: def all(self) -> dict:
return dict(_registry) return dict(_registry)
@@ -546,13 +388,17 @@ class _ChannelsExtension:
"type": "channel", "type": "channel",
"bidirectional": False, "bidirectional": False,
} }
if getattr(channel_class, "Params", None): if channel_class.Params:
channel_schema["params"] = channel_class.Params.model_json_schema() channel_schema["params"] = channel_class.Params.model_json_schema()
if getattr(channel_class, "ReactMessage", None): if channel_class.ClientMessage:
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema() channel_schema[
"client_message"
] = channel_class.ClientMessage.model_json_schema()
channel_schema["bidirectional"] = True channel_schema["bidirectional"] = True
if getattr(channel_class, "DjangoMessage", None): if channel_class.ServerMessage:
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema() channel_schema[
"server_message"
] = channel_class.ServerMessage.model_json_schema()
out[name] = channel_schema out[name] = channel_schema
return out return out
@@ -560,25 +406,15 @@ class _ChannelsExtension:
_registry.clear() _registry.clear()
from mizan_core.registry import register_extension as _register_extension register_extension("channels", _ChannelsExtension())
_register_extension("channels", _ChannelsExtension())
# =============================================================================
# Exports
# =============================================================================
__all__ = [ __all__ = [
# URLs
"urls", "urls",
# Base class "Channel",
"ReactChannel",
# Registration
"register", "register",
"get_channel", "get_channel",
"get_registered_channels", "get_registered_channels",
# ASGI application
"get_websocket_application", "get_websocket_application",
# Schema export
"get_channels_schema", "get_channels_schema",
] ]

View File

@@ -1,7 +1,6 @@
""" """
WebSocket consumer for mizan.channels. WebSocket consumer multiplexing channel subscriptions and RPC calls over one
socket.
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
Protocol: Protocol:
Browser sends: Browser sends:
@@ -16,7 +15,7 @@ Protocol:
Server sends: Server sends:
# Channel messages # Channel messages
{"channel": "chat", "params": {"room": "general"}, "type": "DjangoMessage", "data": {...}} {"channel": "chat", "params": {"room": "general"}, "type": "ServerMessage", "data": {...}}
# RPC responses # RPC responses
{"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}} {"id": "request-id", "ok": true, "data": {"result": {...}, "invalidate": [...]}}
@@ -25,15 +24,8 @@ Protocol:
{"error": "..."} {"error": "..."}
Authentication: Authentication:
Supports both session (cookie) and JWT authentication: Session cookies arrive through AuthMiddlewareStack during the handshake;
- Session: Handled automatically via AuthMiddlewareStack (cookies in handshake) a JWT arrives as a query parameter: ws://localhost/ws/?token=<access_token>
- 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
""" """
import json import json
@@ -43,7 +35,8 @@ from urllib.parse import parse_qs
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from . import get_channel
from mizan.channels import get_channel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,27 +44,23 @@ logger = logging.getLogger(__name__)
class WebSocketRequest: class WebSocketRequest:
""" """
Minimal request adapter for WebSocket context. The request surface ServerFunction reads, backed by a WebSocket scope
instead of an HttpRequest.
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.
""" """
# 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" method = "POST"
def __init__(self, scope: dict, channel_name: str = None): def __init__(self, scope: dict, channel_name: str = None):
self.user = scope.get("user") self.user = scope.get("user")
self.session = scope.get("session", {}) self.session = scope.get("session", {})
self.channel_name = channel_name # For push subscriptions self.channel_name = channel_name
self._scope = scope self._scope = scope
@property @property
def META(self) -> dict: 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", [])) headers = dict(self._scope.get("headers", []))
return { return {
"HTTP_" + k.decode().upper().replace("-", "_"): v.decode() "HTTP_" + k.decode().upper().replace("-", "_"): v.decode()
@@ -80,24 +69,15 @@ class WebSocketRequest:
class DjangoReactConsumer(AsyncJsonWebsocketConsumer): class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
""" """Holds every channel subscription opened over one WebSocket connection."""
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>
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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] = {} self._subscriptions: dict[tuple[str, str], Any] = {}
async def connect(self): async def connect(self):
"""Accept the WebSocket connection, authenticating via JWT if provided.""" """Accept the WebSocket connection, authenticating via JWT if provided."""
# Check for JWT token in query parameters
await self._try_jwt_auth() await self._try_jwt_auth()
await self.accept() await self.accept()
@@ -107,28 +87,23 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
async def _try_jwt_auth(self): 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 An invalid token leaves the scope untouched so session auth still
using JWTUser (no database query). applies; a valid one overwrites whatever session auth resolved.
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.
""" """
# Parse query string for token
query_string = self.scope.get("query_string", b"").decode() query_string = self.scope.get("query_string", b"").decode()
params = parse_qs(query_string) params = parse_qs(query_string)
token_list = params.get("token", []) token_list = params.get("token", [])
if not token_list: if not token_list:
return # No JWT provided, use session auth return
token = token_list[0] token = token_list[0]
if not token: if not token:
return return
# Validate JWT and create JWTUser (no DB query)
try: try:
from mizan.client.jwt import decode_token from mizan.client.jwt import decode_token
from mizan.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
@@ -136,9 +111,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
payload = await sync_to_async(decode_token)(token, expected_type="access") payload = await sync_to_async(decode_token)(token, expected_type="access")
if payload is None: if payload is None:
logger.debug("JWT token invalid or expired") 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) self.scope["user"] = JWTUser(payload)
logger.debug(f"JWT auth successful for user {payload.user_id}") logger.debug(f"JWT auth successful for user {payload.user_id}")
except Exception as e: except Exception as e:
@@ -157,7 +131,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.debug(f"WebSocket disconnected: {self.channel_name}") logger.debug(f"WebSocket disconnected: {self.channel_name}")
async def receive_json(self, content: dict): async def receive_json(self, content: dict):
"""Handle incoming JSON messages.""" """Route one incoming frame by its "action" field."""
action = content.get("action") action = content.get("action")
if action == "subscribe": if action == "subscribe":
@@ -178,11 +152,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
async def _handle_subscribe(self, content: dict): async def _handle_subscribe(self, content: dict):
"""Handle subscription request.""" """Authorize, join the group, and record the subscription."""
channel_name = content.get("channel") channel_name = content.get("channel")
params_dict = content.get("params", {}) params_dict = content.get("params", {})
# Get channel class
channel_class = get_channel(channel_name) channel_class = get_channel(channel_name)
if not channel_class: if not channel_class:
await self.send_json( await self.send_json(
@@ -192,11 +165,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Create subscription key
params_json = json.dumps(params_dict, sort_keys=True) params_json = json.dumps(params_dict, sort_keys=True)
sub_key = (channel_name, params_json) sub_key = (channel_name, params_json)
# Check if already subscribed
if sub_key in self._subscriptions: if sub_key in self._subscriptions:
await self.send_json( await self.send_json(
{ {
@@ -207,7 +178,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Create channel instance
instance = channel_class() instance = channel_class()
instance.user = self.scope.get("user") instance.user = self.scope.get("user")
instance._channel_layer = self.channel_layer instance._channel_layer = self.channel_layer
@@ -215,7 +185,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
instance._registered_name = channel_name instance._registered_name = channel_name
instance._params_dict = params_dict instance._params_dict = params_dict
# Parse params
params_obj = None params_obj = None
if channel_class.Params: if channel_class.Params:
try: try:
@@ -229,7 +198,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Check authorization
try: try:
if params_obj: if params_obj:
authorized = instance.authorize(params_obj) authorized = instance.authorize(params_obj)
@@ -254,7 +222,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Get group and join
try: try:
if params_obj: if params_obj:
group_name = instance.group(params_obj) group_name = instance.group(params_obj)
@@ -271,16 +238,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Store subscription
self._subscriptions[sub_key] = instance self._subscriptions[sub_key] = instance
# Call on_connect hook
try: try:
await instance.on_connect(params_obj) await instance.on_connect(params_obj)
except Exception as e: except Exception as e:
logger.error(f"on_connect error for {channel_name}: {e}") logger.error(f"on_connect error for {channel_name}: {e}")
# Confirm subscription
await self.send_json( await self.send_json(
{ {
"subscribed": True, "subscribed": True,
@@ -292,7 +256,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.debug(f"Subscribed to {channel_name} with params {params_dict}") logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
async def _handle_unsubscribe(self, content: dict): async def _handle_unsubscribe(self, content: dict):
"""Handle unsubscription request.""" """Drop the subscription and leave its groups."""
channel_name = content.get("channel") channel_name = content.get("channel")
params_dict = content.get("params", {}) params_dict = content.get("params", {})
@@ -318,7 +282,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.debug(f"Unsubscribed from {channel_name}") logger.debug(f"Unsubscribed from {channel_name}")
async def _handle_message(self, content: dict): 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") channel_name = content.get("channel")
params_dict = content.get("params", {}) params_dict = content.get("params", {})
data = content.get("data", {}) data = content.get("data", {})
@@ -338,8 +302,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
channel_class = instance.__class__ channel_class = instance.__class__
# Check if channel accepts messages if not channel_class.ClientMessage:
if not channel_class.ReactMessage:
await self.send_json( await self.send_json(
{ {
"error": f"Channel {channel_name} does not accept messages", "error": f"Channel {channel_name} does not accept messages",
@@ -348,9 +311,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Parse message
try: try:
msg = channel_class.ReactMessage(**data) msg = channel_class.ClientMessage(**data)
except Exception as e: except Exception as e:
await self.send_json( await self.send_json(
{ {
@@ -360,16 +322,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Parse params
params_obj = None params_obj = None
if channel_class.Params: if channel_class.Params:
params_obj = channel_class.Params(**params_dict) params_obj = channel_class.Params(**params_dict)
# Handle message
try: try:
response = instance.receive(params_obj, msg) response = instance.receive(params_obj, msg)
# If handler returned a message, broadcast it
if response is not None: if response is not None:
if params_obj: if params_obj:
group_name = instance.group(params_obj) group_name = instance.group(params_obj)
@@ -389,18 +348,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
async def _handle_rpc(self, content: dict): async def _handle_rpc(self, content: dict):
""" """
Handle RPC (server function) call. Run a registered server function.
Protocol: Protocol:
Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}} Request: {"action": "rpc", "id": "request-id", "fn": "function_name", "args": {...}}
Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}} Response: {"id": "request-id", "ok": true, "data": {"result":..., "invalidate":[...]}}
or: {"id": "request-id", "ok": false, "error": {...}} or: {"id": "request-id", "ok": false, "error": {...}}
Security: Only functions registered with @client(websocket=True) are reachable,
- Only functions with @client(websocket=True) are allowed and execute_function validates args against the function's Input model
- Pydantic validation happens BEFORE any function code runs before any function body runs.
- Function must be explicitly registered (no arbitrary code execution)
- User context from WebSocket session is passed to function
""" """
from mizan.client.executor import execute_function, FunctionError from mizan.client.executor import execute_function, FunctionError
from mizan_core.registry import get_function from mizan_core.registry import get_function
@@ -409,7 +366,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
fn_name = content.get("fn") fn_name = content.get("fn")
args = content.get("args", {}) args = content.get("args", {})
# Validate request structure
if not request_id: if not request_id:
await self.send_json( await self.send_json(
{ {
@@ -431,7 +387,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Check if function exists and has websocket=True
fn_class = get_function(fn_name) fn_class = get_function(fn_name)
if fn_class is None: if fn_class is None:
await self.send_json( await self.send_json(
@@ -446,7 +401,6 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Only allow functions explicitly marked with websocket=True
fn_meta = getattr(fn_class, "_meta", {}) fn_meta = getattr(fn_class, "_meta", {})
if not fn_meta.get("websocket"): if not fn_meta.get("websocket"):
await self.send_json( await self.send_json(
@@ -461,20 +415,17 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
) )
return return
# Create request adapter from WebSocket scope
ws_request = WebSocketRequest( ws_request = WebSocketRequest(
self.scope, channel_name=getattr(self, "channel_name", None) self.scope, channel_name=getattr(self, "channel_name", None)
) )
# Execute function (Pydantic validation happens inside execute_function) # execute_function is sync, so it runs in a thread pool
# This is sync, so we need to run it in a thread pool
result = await sync_to_async(execute_function, thread_sensitive=True)( result = await sync_to_async(execute_function, thread_sensitive=True)(
ws_request, ws_request,
fn_name, fn_name,
args, args,
) )
# Send response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
await self.send_json( await self.send_json(
{ {
@@ -488,8 +439,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
} }
) )
else: else:
# the same {result, invalidate, merge} envelope the HTTP RPC path builds, so a # the {result, invalidate, merge} envelope the HTTP RPC path builds
# mutation sent over the socket invalidates exactly as one sent over HTTP
from mizan.client.executor import _resolve_invalidation, _resolve_merges from mizan.client.executor import _resolve_invalidation, _resolve_merges
data = {"result": result.data} data = {"result": result.data}
@@ -508,16 +458,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
async def _handle_ctx(self, content: dict): async def _handle_ctx(self, content: dict):
""" """
Handle a context-bundle fetch. Fetch a context bundle through execute_context.
Protocol: Protocol:
Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}} Request: {"action": "ctx", "id": "request-id", "context": "name", "params": {...}}
Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}} Response: {"id": "request-id", "ok": true, "data": {fn_name: result, ...}}
or: {"id": "request-id", "ok": false, "error": {...}} 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 from mizan.client.executor import execute_context, FunctionError
@@ -563,10 +509,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
async def channel_message(self, event: dict): async def channel_message(self, event: dict):
""" """
Handle messages broadcast to a group. Forward a group broadcast down the socket, carrying the channel name
and params the client routes on.
Called when channel_layer.group_send() is used.
Includes channel name and params so the client can route the message.
""" """
await self.send_json( await self.send_json(
{ {
@@ -579,13 +523,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
async def push_message(self, event: dict): 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. Wire shape: {"type": "push", "topic": "room:42", "data": {...}}
The client receives this to update its local state.
Protocol:
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
""" """
await self.send_json( await self.send_json(
{ {

View File

@@ -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. A topic string ("room:42", "user:123:notifications") maps onto one channel
layer group; subscribing a connection adds its channel name to that group,
Usage: and pushing sends a "push.message" event to every member.
# 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")
""" """
from typing import TYPE_CHECKING import logging
from asgiref.sync import async_to_sync
from pydantic import BaseModel from pydantic import BaseModel
# Lazy import to avoid import errors when channels is not installed logger = logging.getLogger(__name__)
# (e.g., during schema generation)
if TYPE_CHECKING:
from channels.layers import BaseChannelLayer
def _get_channel_layer() -> "BaseChannelLayer | None": def _get_channel_layer():
"""Get channel layer, returning None if channels is not installed.""" """The configured channel layer, or None when django-channels is absent."""
try: try:
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
except ImportError as e:
return get_channel_layer() logger.warning("django-channels is not installed, push is inert: %s", e)
except ImportError:
return None return None
return get_channel_layer()
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)
def get_topic_group_name(topic: str) -> str: 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 Convert a topic to a channel layer group name. Group names allow ASCII
# Replace colons with underscores alphanumerics plus hyphens, underscores and periods, so the topic
separator becomes an underscore.
"""
return topic.replace(":", "_") return topic.replace(":", "_")
def subscribe(request, topic: str) -> None: 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 An HTTP request carries no channel_name, so there is nothing to add.
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"
""" """
channel_name = getattr(request, "channel_name", None) channel_name = getattr(request, "channel_name", None)
if not channel_name: if not channel_name:
# HTTP request, not WebSocket - can't subscribe
return return
channel_layer = _get_channel_layer() channel_layer = _get_channel_layer()
@@ -70,17 +49,11 @@ def subscribe(request, topic: str) -> None:
return return
group_name = get_topic_group_name(topic) 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: def unsubscribe(request, topic: str) -> None:
""" """Remove this WebSocket connection from a topic's group."""
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
"""
channel_name = getattr(request, "channel_name", None) channel_name = getattr(request, "channel_name", None)
if not channel_name: if not channel_name:
return return
@@ -90,42 +63,29 @@ def unsubscribe(request, topic: str) -> None:
return return
group_name = get_topic_group_name(topic) 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: def push(topic: str, data: dict | BaseModel) -> None:
""" """Send data to every connection subscribed to a topic."""
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"}
})
"""
channel_layer = _get_channel_layer() channel_layer = _get_channel_layer()
if not channel_layer: if not channel_layer:
import logging logger.warning(
logging.getLogger(__name__).warning(
"No channel layer configured, cannot push to topic '%s'", topic "No channel layer configured, cannot push to topic '%s'", topic
) )
return return
# Convert Pydantic model to dict if needed
if isinstance(data, BaseModel): if isinstance(data, BaseModel):
data = data.model_dump() data = data.model_dump()
group_name = get_topic_group_name(topic) group_name = get_topic_group_name(topic)
_async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
group_name, 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, "topic": topic,
"data": data, "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 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() channel_layer = _get_channel_layer()
if not channel_layer: if not channel_layer:
logger.warning(
"No channel layer configured, cannot push to topic '%s'", topic
)
return return
if isinstance(data, BaseModel): if isinstance(data, BaseModel):

View File

@@ -1,19 +1,12 @@
""" """
mizan.client - Server function implementation. The server-function surface: the `client` decorator and `ServerFunction` base
come from `mizan_core`; execution and dispatch are Django-specific and live in
This subpackage contains everything needed to make server functions work: `mizan.client.executor`.
- 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
""" """
# Register the Django framework response base so view-path detection works # Registering the Django response base has to happen before any
# in mizan_core.client.function. Has to happen before any @client-decorated # @client-decorated code is evaluated, or view-path detection in
# code is evaluated. # mizan_core.client.function cannot recognize a returned HttpResponse.
from django.http import HttpResponseBase as _HttpResponseBase from django.http import HttpResponseBase as _HttpResponseBase
from mizan_core.client.function import set_framework_response_base as _set_response_base from mizan_core.client.function import set_framework_response_base as _set_response_base
_set_response_base(_HttpResponseBase) _set_response_base(_HttpResponseBase)
@@ -39,7 +32,7 @@ from mizan_core.client.function import (
create_form_functions, create_form_functions,
) )
from .executor import ( from mizan.client.executor import (
execute_function, execute_function,
function_call_view, function_call_view,
ErrorCode, ErrorCode,

View File

@@ -1,17 +1,10 @@
""" """
mizan Function Executor Dispatch for registered server functions over HTTP.
Handles execution of server functions. Input is validated against the function's Pydantic Input before the body ever
This is the core of the "Server Functions" feature - callable from React runs. Authentication is auto-detected per request: an X-Mizan-Token (MWT) or
without REST boilerplate. an Authorization Bearer (JWT) header is self-authenticating and bypasses CSRF;
anything else falls through to session auth with CSRF enforced.
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
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,7 +14,7 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from functools import wraps 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.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
from django.views.decorators.csrf import csrf_protect 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_core.registry import get_function, get_context_groups
from mizan.setup.settings import get_settings from mizan.setup.settings import get_settings
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,50 +89,40 @@ def _check_auth_requirement(
auth_requirement: str | Callable | None, auth_requirement: str | Callable | None,
) -> FunctionError | 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: The built-in checks read only flags already on request.user — a JWTUser or
request: The Django HttpRequest (with user set) a session User — so none of them hit the database. A callable may.
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.
""" """
if auth_requirement is None: if auth_requirement is None:
return None return None
user = request.user user = request.user
# Handle callable auth
if callable(auth_requirement): if callable(auth_requirement):
try: try:
result = auth_requirement(request) result = auth_requirement(request)
if result: if result:
return None # Authorized return None
else: else:
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message="Access denied", message="Access denied",
) )
except PermissionError as e: except PermissionError as e:
# Custom error message from the callable
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message=str(e) or "Access denied", 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): if not getattr(user, "is_authenticated", False):
return FunctionError( return FunctionError(
code=ErrorCode.UNAUTHORIZED, code=ErrorCode.UNAUTHORIZED,
message="Authentication required", message="Authentication required",
) )
# Check staff requirement
if auth_requirement == "staff": if auth_requirement == "staff":
if not getattr(user, "is_staff", False): if not getattr(user, "is_staff", False):
return FunctionError( return FunctionError(
@@ -150,7 +130,6 @@ def _check_auth_requirement(
message="Staff access required", message="Staff access required",
) )
# Check superuser requirement
elif auth_requirement == "superuser": elif auth_requirement == "superuser":
if not getattr(user, "is_superuser", False): if not getattr(user, "is_superuser", False):
return FunctionError( return FunctionError(
@@ -168,7 +147,7 @@ def _purge_cache_for_invalidation(
invalidate: list, invalidate: list,
request: HttpRequest | None = None, request: HttpRequest | None = 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() cache = get_cache()
if cache is None: if cache is None:
return return
@@ -199,34 +178,27 @@ def _purge_cache_for_invalidation(
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]: 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 ("context", "user", None) — full context invalidation
("function", "user_profile", "user") — function within context ("function", "user_profile", "user") — function within context
""" """
groups = get_context_groups() groups = get_context_groups()
# Check if it's a context name directly
if target_name in groups: if target_name in groups:
return ("context", target_name, None) return ("context", target_name, None)
# Check if it's a function name within a context
for ctx_name, fn_names in groups.items(): for ctx_name, fn_names in groups.items():
if target_name in fn_names: if target_name in fn_names:
return ("function", target_name, ctx_name) return ("function", target_name, ctx_name)
# Not a context or context function — treat as context name anyway # An unregistered name is treated as a context so invalidation still
# (it might be a non-context function or an as-yet-unregistered context) # propagates rather than being silently dropped.
return ("context", target_name, None) return ("context", target_name, None)
def _get_context_param_names(context_name: str) -> set[str]: def _get_context_param_names(context_name: str) -> set[str]:
""" """Union of the Input field names across every function in a context."""
Get the set of parameter names used by functions in a context.
Returns the union of all Input field names across context functions.
"""
groups = get_context_groups() groups = get_context_groups()
fn_names = groups.get(context_name, []) fn_names = groups.get(context_name, [])
param_names: set[str] = set() param_names: set[str] = set()
@@ -247,18 +219,14 @@ def _resolve_invalidation(
input_data: dict[str, Any] | None = None, input_data: dict[str, Any] | None = None,
) -> list[str | dict[str, Any]] | 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 A target is scoped to specific params when the mutation's input argument
with the context's params by name, auto-scope. names overlap the context's param names; otherwise the whole context is
Tier 2: Auth inference — Edge-side concern, not handled here. invalidated. A function-level target is keyed by the function name.
Tier 3: Broad fallback — invalidate all instances.
Also handles function-level targeting: affects='user_profile' resolves The returned list serializes into both the JSON body and the header.
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.
""" """
if view_class is None: if view_class is None:
return None return None
@@ -275,7 +243,6 @@ def _resolve_invalidation(
if target["type"] == "context": if target["type"] == "context":
target_name = target["name"] target_name = target["name"]
elif target["type"] == "function" and target.get("context"): elif target["type"] == "function" and target.get("context"):
# Function-level: use the function name as the invalidation key
target_name = target["name"] target_name = target["name"]
else: else:
continue continue
@@ -284,11 +251,9 @@ def _resolve_invalidation(
continue continue
seen.add(target_name) seen.add(target_name)
# Resolve the context this target belongs to (for param lookup)
resolved = _resolve_affects_target(target_name) resolved = _resolve_affects_target(target_name)
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1] ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
# Tier 1: argument name matching
if input_data and ctx_for_params: if input_data and ctx_for_params:
context_params = _get_context_param_names(ctx_for_params) context_params = _get_context_param_names(ctx_for_params)
matched = { matched = {
@@ -299,7 +264,6 @@ def _resolve_invalidation(
result.append({"context": target_name, "params": matched}) result.append({"context": target_name, "params": matched})
continue continue
# Tier 3: broad fallback
result.append(target_name) result.append(target_name)
return result if result else None return result if result else None
@@ -311,15 +275,12 @@ def _resolve_merges(
result_data: Any, result_data: Any,
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
""" """
Resolve merge targets from @client(merge=...). Turn a mutation's `merge` metadata into `{context, slot, value, params?}`
entries. `slot` is the function-name inside the context bundle the value
Each entry is `{context, slot, value, params?}` — `slot` is the lands in, resolved here by matching the mutation's declared Output against
function-name inside the context bundle the value lands in, resolved each context-function's Output. Entries whose slot is ambiguous are
server-side by matching the mutation's return type against each dropped, and params are scoped the same way `_resolve_invalidation` scopes
context-function's return type. Kernel does no shape inference. them.
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
Entries whose slot can't be uniquely resolved are dropped.
""" """
if view_class is None: if view_class is None:
return 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: 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: if mutation_output is None:
return None return None
groups = get_context_groups() groups = get_context_groups()
@@ -378,12 +339,11 @@ def _format_invalidate_header(
invalidate: list[str | dict[str, Any]], invalidate: list[str | dict[str, Any]],
) -> str: ) -> 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"] → "user"
["user", "notifications"] → "user, notifications" ["user", "notifications"] → "user, notifications"
[{"context": "user", "params": {"user_id": 5}}] [{"context": "user", "params": {"user_id": 5}}]
@@ -417,22 +377,17 @@ def execute_function(
input_data: dict[str, Any] | None = None, input_data: dict[str, Any] | None = None,
) -> "FunctionResult | FunctionError | HttpResponseBase": ) -> "FunctionResult | FunctionError | HttpResponseBase":
""" """
Execute a registered server function. Look up, authorize, validate, and run a registered server function.
Args: Returns the function's HttpResponse untouched when it returned one,
request: The Django HttpRequest otherwise a FunctionResult or FunctionError.
fn_name: Name of the registered function
input_data: Input data to pass to the function
Returns:
FunctionResult on success, FunctionError on failure
""" """
from django.conf import settings from django.conf import settings
# Look up the function by name
view_class = get_function(fn_name) view_class = get_function(fn_name)
if view_class is None: 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: if settings.DEBUG:
message = f"Function '{fn_name}' not found" message = f"Function '{fn_name}' not found"
else: else:
@@ -442,7 +397,6 @@ def execute_function(
message=message, message=message,
) )
# Reject private functions from RPC dispatch
meta = getattr(view_class, "_meta", {}) meta = getattr(view_class, "_meta", {})
if meta.get("private"): if meta.get("private"):
return FunctionError( return FunctionError(
@@ -450,36 +404,28 @@ def execute_function(
message="Function is not client-callable", 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_requirement = meta.get("auth")
auth_error = _check_auth_requirement(request, auth_requirement) auth_error = _check_auth_requirement(request, auth_requirement)
if auth_error is not None: if auth_error is not None:
return auth_error return auth_error
# Instantiate the view with the request
view = view_class(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) 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 input_cls = view.Input
if input_cls is None and is_form_multipart: 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 validated_input = input_data
elif input_cls is BaseModel: elif input_cls is BaseModel:
has_input = False has_input = False
validated_input = None validated_input = None
else: else:
# Check if it has any fields defined
has_input = bool(input_cls.model_fields) if input_cls else False has_input = bool(input_cls.model_fields) if input_cls else False
# Validate input against Pydantic schema
try: try:
if input_data: if input_data:
# Ensure input_data is a dict (not array or other type)
if not isinstance(input_data, dict): if not isinstance(input_data, dict):
return FunctionError( return FunctionError(
code=ErrorCode.BAD_REQUEST, code=ErrorCode.BAD_REQUEST,
@@ -488,11 +434,11 @@ def execute_function(
) )
validated_input = input_cls(**input_data) validated_input = input_cls(**input_data)
elif has_input: elif has_input:
# Check if function requires input fields
input_schema = input_cls.model_json_schema() input_schema = input_cls.model_json_schema()
required_fields = input_schema.get("required", []) required_fields = input_schema.get("required", [])
if required_fields: 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} errors = {field: ["Field required"] for field in required_fields}
return FunctionError( return FunctionError(
code=ErrorCode.VALIDATION_ERROR, code=ErrorCode.VALIDATION_ERROR,
@@ -501,10 +447,8 @@ def execute_function(
) )
validated_input = input_cls() validated_input = input_cls()
else: else:
# No input expected, create empty model
validated_input = None validated_input = None
except ValidationError as e: except ValidationError as e:
# Convert Pydantic errors to our format
errors = {} errors = {}
for error in e.errors(): for error in e.errors():
field = ".".join(str(loc) for loc in error["loc"]) field = ".".join(str(loc) for loc in error["loc"])
@@ -518,7 +462,6 @@ def execute_function(
details={"fields": errors}, details={"fields": errors},
) )
# Execute the function
try: try:
output = view.call(validated_input) output = view.call(validated_input)
except NotImplementedError as e: except NotImplementedError as e:
@@ -528,28 +471,24 @@ def execute_function(
message=str(e), message=str(e),
) )
except PermissionError as e: except PermissionError as e:
# Functions can raise PermissionError for auth issues
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message=str(e) or "Permission denied", message=str(e) or "Permission denied",
) )
except Exception as e: except Exception as e:
# Log the full exception for debugging
logger.exception(f"Error executing function {fn_name}") logger.exception(f"Error executing function {fn_name}")
return FunctionError( return FunctionError(
code=ErrorCode.INTERNAL_ERROR, code=ErrorCode.INTERNAL_ERROR,
message="An internal error occurred", 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__} details={"type": type(e).__name__}
if logger.isEnabledFor(logging.DEBUG) if logger.isEnabledFor(logging.DEBUG)
else None, else None,
) )
# Return-type branching: HttpResponse (view path) vs data (RPC path)
from django.http import HttpResponseBase from django.http import HttpResponseBase
if isinstance(output, HttpResponseBase): if isinstance(output, HttpResponseBase):
# View path — add invalidation header + purge origin cache
invalidate = _resolve_invalidation(view_class, input_data) invalidate = _resolve_invalidation(view_class, input_data)
if invalidate: if invalidate:
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate) output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
@@ -557,9 +496,8 @@ def execute_function(
output["Cache-Control"] = "no-store" output["Cache-Control"] = "no-store"
return output return output
# RPC path — serialize output. to_jsonable_python walks BaseModel / # to_jsonable_python walks BaseModel / list / dict recursively, so nested
# list / dict recursively, so list[BaseModel] (and nested shapes) come # shapes need no per-shape branch here.
# out wire-ready without a per-shape branch.
from pydantic_core import to_jsonable_python from pydantic_core import to_jsonable_python
return FunctionResult(data=to_jsonable_python(output)) return FunctionResult(data=to_jsonable_python(output))
@@ -567,10 +505,9 @@ def execute_function(
def _try_mwt_auth(request: HttpRequest) -> bool: def _try_mwt_auth(request: HttpRequest) -> bool:
""" """
Attempt to authenticate the request using MWT (Mizan Web Token). Authenticate from the X-Mizan-Token header, setting request.user to an
MWTUser on success. False means no header, no configured secret, or a
Checks the X-Mizan-Token header. If present and valid, sets request.user token that did not verify.
to an MWTUser. Returns True on success, False if no MWT header or invalid.
""" """
token = request.META.get("HTTP_X_MIZAN_TOKEN", "") token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
if not token: if not token:
@@ -607,18 +544,10 @@ def _has_mwt_header(request: HttpRequest) -> bool:
def _try_jwt_auth(request: HttpRequest) -> bool: def _try_jwt_auth(request: HttpRequest) -> bool:
""" """
Attempt to authenticate the request using JWT. Authenticate from an Authorization Bearer token, setting request.user to a
JWTUser built from the claims — no database query. False means no bearer
If Authorization header contains a valid Bearer token, authenticates header or a token that did not verify; the caller must then reject rather
the request and sets request.user to a JWTUser. Returns True if JWT than fall back to session auth.
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.
""" """
auth_header = request.META.get("HTTP_AUTHORIZATION", "") auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header.startswith("Bearer "): if not auth_header.startswith("Bearer "):
@@ -636,11 +565,13 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
if payload is None: if payload is None:
return False return False
# Create JWTUser from token claims - NO DATABASE QUERY
request.user = JWTUser(payload) request.user = JWTUser(payload)
request._mizan_jwt_authenticated = True request._mizan_jwt_authenticated = True
return True return True
except Exception: except Exception:
logging.getLogger("mizan.jwt").warning(
"JWT authentication failed unexpectedly", exc_info=True
)
return False return False
@@ -652,19 +583,15 @@ def _has_jwt_header(request: HttpRequest) -> bool:
def _csrf_protect_unless_token(view_func): def _csrf_protect_unless_token(view_func):
""" """
Decorator that applies CSRF protection unless token auth is used. 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
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer). invalid rejects the request outright rather than falling back to session
Both are self-authenticating, so CSRF protection is not needed. auth.
Security: If a token is provided but invalid, reject the request - do NOT
fall back to session auth.
""" """
csrf_protected_view = csrf_protect(view_func) csrf_protected_view = csrf_protect(view_func)
@wraps(view_func) @wraps(view_func)
def wrapper(request: HttpRequest, *args, **kwargs): def wrapper(request: HttpRequest, *args, **kwargs):
# MWT takes priority
if _has_mwt_header(request): if _has_mwt_header(request):
if _try_mwt_auth(request): if _try_mwt_auth(request):
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@@ -673,7 +600,6 @@ def _csrf_protect_unless_token(view_func):
message="Invalid or expired MWT", message="Invalid or expired MWT",
).to_response(status=401) ).to_response(status=401)
# Legacy JWT fallback
if _has_jwt_header(request): if _has_jwt_header(request):
if _try_jwt_auth(request): if _try_jwt_auth(request):
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@@ -682,7 +608,6 @@ def _csrf_protect_unless_token(view_func):
message="Invalid or expired JWT token", message="Invalid or expired JWT token",
).to_response(status=401) ).to_response(status=401)
# No token — session auth with CSRF
return csrf_protected_view(request, *args, **kwargs) return csrf_protected_view(request, *args, **kwargs)
return wrapper return wrapper
@@ -691,52 +616,25 @@ def _csrf_protect_unless_token(view_func):
@_csrf_protect_unless_token @_csrf_protect_unless_token
def function_call_view(request: HttpRequest) -> JsonResponse: 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): A JSON body carries `{"fn": ..., "args": {...}}`. A multipart body carries
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed) `fn` as a form field alongside the form's own fields, and its parsed data
- Session: Cookie-based with X-CSRFToken header (CSRF required) and files are attached to the request for the form function to pick up.
Endpoint: POST /api/mizan/call/ Success answers `{"result": ...}`, plus `invalidate` / `merge` when the
function declared them; failure answers the FunctionError shape.
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": { ... }
}
""" """
# Only allow POST
if request.method != "POST": if request.method != "POST":
return FunctionError( return FunctionError(
code=ErrorCode.BAD_REQUEST, code=ErrorCode.BAD_REQUEST,
message="Only POST method allowed", message="Only POST method allowed",
).to_response(status=405) ).to_response(status=405)
# Check content type to determine parsing method
content_type = request.content_type or "" content_type = request.content_type or ""
is_multipart = content_type.startswith("multipart/form-data") is_multipart = content_type.startswith("multipart/form-data")
if is_multipart: if is_multipart:
# Multipart form data - used by form submit functions
fn_name = request.POST.get("fn") fn_name = request.POST.get("fn")
if not fn_name: if not fn_name:
return FunctionError( return FunctionError(
@@ -744,15 +642,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
message="Missing 'fn' field", message="Missing 'fn' field",
).to_response() ).to_response()
# Get form data (excluding 'fn')
input_data = {k: v for k, v in request.POST.dict().items() if k != "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_data = input_data
request._mizan_form_files = request.FILES request._mizan_form_files = request.FILES
else: else:
# JSON body - standard RPC
try: try:
if request.body: if request.body:
body = json.loads(request.body) body = json.loads(request.body)
@@ -767,7 +662,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
message="Invalid JSON in request body", message="Invalid JSON in request body",
).to_response() ).to_response()
# Extract function name and args
fn_name = body.get("fn") fn_name = body.get("fn")
if not fn_name: if not fn_name:
return FunctionError( return FunctionError(
@@ -777,15 +671,13 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
input_data = body.get("args") input_data = body.get("args")
# Execute the function
result = execute_function(request, fn_name, input_data) 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 from django.http import HttpResponseBase
if isinstance(result, HttpResponseBase): if isinstance(result, HttpResponseBase):
return result return result
# Return appropriate response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
status = { status = {
ErrorCode.NOT_FOUND: 404, ErrorCode.NOT_FOUND: 404,
@@ -798,7 +690,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
}.get(result.code, 400) }.get(result.code, 400)
return result.to_response(status=status) return result.to_response(status=status)
# RPC path — build response with server-driven invalidation
view_class = get_function(fn_name) view_class = get_function(fn_name)
response_data = {"result": result.data} response_data = {"result": result.data}
invalidate_contexts = _resolve_invalidation(view_class, input_data) invalidate_contexts = _resolve_invalidation(view_class, input_data)
@@ -825,18 +716,8 @@ def execute_context(
params: dict[str, str], params: dict[str, str],
) -> FunctionResult | FunctionError: ) -> FunctionResult | FunctionError:
""" """
Execute all functions in a named context with merged params. 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.
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
""" """
groups = get_context_groups() groups = get_context_groups()
fn_names = groups.get(context_name) fn_names = groups.get(context_name)
@@ -852,7 +733,6 @@ def execute_context(
if view_class is None: if view_class is None:
continue continue
# Filter params to only those in this function's Input schema
input_cls = getattr(view_class, "Input", None) input_cls = getattr(view_class, "Input", None)
if input_cls and input_cls is not BaseModel and input_cls.model_fields: if input_cls and input_cls is not BaseModel and input_cls.model_fields:
fn_params = { fn_params = {
@@ -871,13 +751,9 @@ def execute_context(
def _jwt_auth_only(view_func): def _jwt_auth_only(view_func):
""" """Token auth for GET views: MWT first, then JWT. GET needs no CSRF."""
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
Checks MWT first, then legacy JWT.
"""
@wraps(view_func) @wraps(view_func)
def wrapper(request: HttpRequest, *args, **kwargs): def wrapper(request: HttpRequest, *args, **kwargs):
# MWT takes priority
if _has_mwt_header(request): if _has_mwt_header(request):
if _try_mwt_auth(request): if _try_mwt_auth(request):
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@@ -886,7 +762,6 @@ def _jwt_auth_only(view_func):
message="Invalid or expired MWT", message="Invalid or expired MWT",
).to_response(status=401) ).to_response(status=401)
# Legacy JWT fallback
if _has_jwt_header(request): if _has_jwt_header(request):
if _try_jwt_auth(request): if _try_jwt_auth(request):
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
@@ -895,7 +770,6 @@ def _jwt_auth_only(view_func):
message="Invalid or expired JWT token", message="Invalid or expired JWT token",
).to_response(status=401) ).to_response(status=401)
# No token — session auth (no CSRF needed for GET)
return view_func(request, *args, **kwargs) return view_func(request, *args, **kwargs)
return wrapper return wrapper
@@ -904,18 +778,12 @@ def _jwt_auth_only(view_func):
@_jwt_auth_only @_jwt_auth_only
def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse: 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&param2=val2 The context's effective cache policy and revision are the strictest across
its functions: any function declaring cache=False disables caching for the
Response: raw bundled data, CDN-cacheable. whole bundle, and the shortest declared TTL wins.
{
"user_profile": { ... },
"user_orders": [ ... ]
}
Headers:
Cache-Control: public, max-age=0, s-maxage=31536000
""" """
if request.method != "GET": if request.method != "GET":
return FunctionError( return FunctionError(
@@ -925,7 +793,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
params = request.GET.dict() params = request.GET.dict()
# Resolve effective rev and cache policy across all functions in this context
_cache_log = logging.getLogger("mizan.cache") _cache_log = logging.getLogger("mizan.cache")
groups = get_context_groups() groups = get_context_groups()
fn_names = groups.get(context_name, []) fn_names = groups.get(context_name, [])
@@ -947,7 +814,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
else: else:
effective_cache = min(effective_cache, fn_cache) effective_cache = min(effective_cache, fn_cache)
# Origin-side cache lookup (skip if cache=False)
cache_backend = get_cache() cache_backend = get_cache()
cache_settings = get_settings() cache_settings = get_settings()
user_id = None user_id = None
@@ -988,14 +854,14 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
error_response["Cache-Control"] = "no-store" error_response["Cache-Control"] = "no-store"
return error_response 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}) response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
# Mizan's protocol layers handle caching (origin Redis, Edge Worker). # Caching happens in the origin cache below and at the edge, both of which
# The browser and non-Mizan intermediaries must not cache. # can be purged; a browser cache cannot, so it must not hold this.
response["Cache-Control"] = "no-store" response["Cache-Control"] = "no-store"
# Store in origin-side cache (skip if cache=False)
if use_cache: if use_cache:
try: try:
cache_put( cache_put(

View File

@@ -1,19 +1,9 @@
""" """
mizan.client.jwt - JWT authentication for server functions. Token and settings names from `mizan.jwt`, re-exported under `mizan.client`
for the executor and the WebSocket consumer. The Ninja auth class is
Provides: deliberately absent here — reach for `mizan.jwt.security` for that.
- 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 utilities (re-exports from django_jwt_session)
from mizan.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
create_access_token, create_access_token,
@@ -25,7 +15,6 @@ from mizan.jwt.tokens import (
JWTUser, JWTUser,
) )
# Settings
from mizan.jwt.settings import get_settings, JWTSettings from mizan.jwt.settings import get_settings, JWTSettings
__all__ = [ __all__ = [

View File

@@ -1,19 +1,11 @@
""" """
Mizan Edge Manifest Generator. Builds the Edge manifest: a static JSON document mapping each context to its
API endpoint, page routes, and parameter names.
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
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import Any from typing import Any
from mizan_core.registry import get_context_groups, get_registry 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, view_urls: dict[str, list[str]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Generate the Edge manifest — a static JSON mapping contexts to URL Build the manifest dict.
patterns and params for CDN cache purging.
The manifest is consumed by Mizan Edge at deploy time. When Edge Each context entry carries the union of its functions' Input field names,
receives X-Mizan-Invalidate: user;user_id=5, it: its API endpoint under `base_url`, any page routes declared via
1. Looks up 'user' in the manifest `@client(route=...)`, and a render strategy derived from whether any
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/ parameter is user-scoped. Each mutation entry carries the contexts it
3. Purges the resolved URLs + the context API endpoint affects and the parameter names shared with those contexts.
Args: Args:
base_url: The Mizan API mount point (default: /api/mizan) base_url: The Mizan API mount point (default: /api/mizan)
view_urls: Optional mapping of context names to URL patterns for view_urls: Extra page routes per context name, merged with the ones
view-path functions. These are URLs that Edge should read off `@client(route=...)`.
also purge when a context is invalidated.
Returns: Returns:
Manifest dict suitable for JSON serialization. Manifest dict suitable for JSON serialization.

View File

@@ -1,153 +1,26 @@
""" """
mizanFormMixin - Turn Django Forms into server functions. mizanFormMixin exposes a Django Form as the server functions
`<name>.schema`, `<name>.validate`, and `<name>.submit`, registered from
This mixin transforms any Django Form into mizan server functions, `__init_subclass__` off the `mizan = mizanFormMeta(...)` attribute.
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
""" """
from __future__ import annotations 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 import forms
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel, create_model from pydantic import BaseModel, create_model
if TYPE_CHECKING: logger = logging.getLogger("mizan.forms")
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
class mizanFormMeta(BaseModel): class mizanFormMeta(BaseModel):
""" """
Configuration for a mizan form. Per-form configuration. `name` is the API identifier the three registered
function names are built from; the rest are carried into the emitted schema.
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)
""" """
# Required # Required
@@ -169,109 +42,79 @@ class mizanFormMeta(BaseModel):
class mizanFormMixin: 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: `get_init_kwargs`, `on_submit_success` and `on_submit_failure` are the three
override points. Each is called unconditionally, so the definitions here are
class ContactForm(mizanFormMixin, forms.Form): what a form that overrides none of them does.
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
""" """
# Configuration - subclasses must define this # Configuration - subclasses must define this
mizan: ClassVar[mizanFormMeta] 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 _mizan_registered: ClassVar[bool] = False
@classmethod @classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]: def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
""" """
Override to provide extra kwargs for form instantiation. Kwargs merged into every instantiation of this form. `request` is passed
through to a form whose `__init__` names it; Django's own `BaseForm`
Common use: pass request or user to forms that need them. 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.
Example:
@classmethod
def get_init_kwargs(cls, request):
return {"request": request, "user": request.user}
""" """
accepted = inspect.signature(cls.__init__).parameters
if "request" in accepted:
return {"request": request}
return {} return {}
def on_submit_success(self, request: HttpRequest) -> dict | None: def on_submit_success(self, request: HttpRequest) -> dict | None:
""" """
Called after successful form validation and submission. Handle a validated submission. A returned dict is carried in the
response payload; a ModelForm's `save()` returns a model instance, which
Override to handle the form submission logic. is not payload, so only a dict result is forwarded.
Return a dict to include data in the response.
Example:
def on_submit_success(self, request):
self.save()
return {"id": self.instance.pk}
""" """
# Default: call save() if available
if hasattr(self, "save"): if hasattr(self, "save"):
result = self.save() result = self.save()
# If save returns something serializable, include it
if isinstance(result, dict): if isinstance(result, dict):
return result return result
return None 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. Handle a rejected submission. The per-field errors already travel to the
client in the response body, so the rejection is recorded server-side
Override to add custom error handling, logging, etc. 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): def __init_subclass__(cls, **kwargs):
"""Auto-register when a concrete form class is defined.""" """Auto-register when a concrete form class is defined."""
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
# Only register concrete forms with mizan config defined
if _is_concrete_mizan_form(cls): if _is_concrete_mizan_form(cls):
_register_form_as_server_functions(cls) _register_form_as_server_functions(cls)
def _is_concrete_mizan_form(cls: type) -> bool: def _is_concrete_mizan_form(cls: type) -> bool:
""" """
Check if a class is a concrete mizan form ready for registration. True when `cls` carries its own mizanFormMeta, is a Django form, and has
not already been registered.
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)
""" """
# 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") mizan_config = cls.__dict__.get("mizan")
if not isinstance(mizan_config, mizanFormMeta): if not isinstance(mizan_config, mizanFormMeta):
return False return False
# Must be a Django form
if not issubclass(cls, forms.BaseForm): if not issubclass(cls, forms.BaseForm):
return False return False
# Check if already registered (handle re-imports gracefully)
if cls.__dict__.get("_mizan_registered", False): if cls.__dict__.get("_mizan_registered", False):
return 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: def _register_form_as_server_functions(form_class: type) -> None:
""" """
Register a Django Form class as mizan server functions. Register `{name}.schema`, `{name}.validate`, and `{name}.submit` for
`form_class`, plus the formset trio when `enable_formset` is set.
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.
""" """
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation from mizan.forms.schemas import (
from .schema_utils import build_form_schema FormSchema,
from .validation_utils import validate_form_instance 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.registry import register
from mizan_core.client.function import ServerFunction from mizan_core.client.function import ServerFunction
config: mizanFormMeta = form_class.mizan config: mizanFormMeta = form_class.mizan
form_name = config.name form_name = config.name
# Mark as registered
form_class._mizan_registered = True form_class._mizan_registered = True
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact") # "contact" -> "Contact", "reset_password" -> "ResetPassword"
pascal_name = "".join( pascal_name = "".join(
word.capitalize() word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_") 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 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( FormSchemaInput = create_model(
f"{pascal_name}SchemaInput", f"{pascal_name}SchemaInput",
data=(dict[str, Any], {}), data=(dict[str, Any], {}),
@@ -337,7 +166,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
"form": True, "form": True,
"form_name": form_name, "form_name": form_name,
"form_role": "schema", "form_role": "schema",
"form_class": form_class, # Store reference for schema generation "form_class": form_class,
} }
def call(self, input) -> FormSchema: 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 {}, data=input.data if input else {},
**init_kwargs, **init_kwargs,
) )
# Override with mizanFormMeta values # mizanFormMeta wins over anything derived from the form class.
if config.title is not None: if config.title is not None:
schema.title = config.title schema.title = config.title
if config.subtitle is not None: if config.subtitle is not None:
schema.subtitle = config.subtitle schema.subtitle = config.subtitle
schema.submit_label = config.submit_label schema.submit_label = config.submit_label
# Behavior settings are nested in schema.meta
schema.meta.live_validation = config.live_validation schema.meta.live_validation = config.live_validation
schema.meta.live_form_errors = config.live_form_errors schema.meta.live_form_errors = config.live_form_errors
schema.meta.refetch_schema_on_validate = config.refetch_schema_on_validate 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 # 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( FormValidateInput = create_model(
f"{pascal_name}ValidateInput", f"{pascal_name}ValidateInput",
data=(dict[str, Any], ...), data=(dict[str, Any], ...),
@@ -385,7 +213,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
def call(self, input) -> FormValidation: def call(self, input) -> FormValidation:
init_kwargs = form_class.get_init_kwargs(self.request) init_kwargs = form_class.get_init_kwargs(self.request)
# Input data is already a dict
data = input.data data = input.data
_, validation = validate_form_instance( _, validation = validate_form_instance(
form_class, form_class,
@@ -404,32 +231,25 @@ def _register_form_as_server_functions(form_class: type) -> None:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
class SubmitFunction(ServerFunction): class SubmitFunction(ServerFunction):
""" # Input=None signals the executor to pass the raw dict through, since
Submit function handles both JSON and multipart/form-data. # the Django form owns validation.
Input = None
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
_meta: ClassVar[dict] = { _meta: ClassVar[dict] = {
"form": True, "form": True,
"form_name": form_name, "form_name": form_name,
"form_role": "submit", "form_role": "submit",
"multipart": True, # Signal that this function accepts multipart "multipart": True,
} }
def call(self, input) -> FormSubmitPass | FormSubmitFail: def call(self, input) -> FormSubmitPass | FormSubmitFail:
"""Execute form submission."""
request = self.request 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"): if hasattr(request, "_mizan_form_data"):
data = request._mizan_form_data data = request._mizan_form_data
files = request._mizan_form_files files = request._mizan_form_files
elif input is not None: elif input is not None:
# JSON input - already a dict
data = input if isinstance(input, dict) else input.model_dump() data = input if isinstance(input, dict) else input.model_dump()
files = None files = None
else: else:
@@ -438,7 +258,6 @@ def _register_form_as_server_functions(form_class: type) -> None:
init_kwargs = form_class.get_init_kwargs(request) init_kwargs = form_class.get_init_kwargs(request)
# Create and validate form
form, validation = validate_form_instance( form, validation = validate_form_instance(
form_class, form_class,
data=data, data=data,
@@ -447,11 +266,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
) )
if form.is_valid(): if form.is_valid():
# Call the form's on_submit_success
result_data = form.on_submit_success(request) result_data = form.on_submit_success(request)
return FormSubmitPass(success=True, data=result_data) return FormSubmitPass(success=True, data=result_data)
# Call the form's on_submit_failure
form.on_submit_failure(request, validation) form.on_submit_failure(request, validation)
return FormSubmitFail(success=False, errors=validation) return FormSubmitFail(success=False, errors=validation)
@@ -472,36 +289,34 @@ def _register_formset_functions(
form_class: type, form_class: type,
form_name: str, form_name: str,
) -> None: ) -> None:
"""Register formset server functions for a form.""" """Register the `{name}.formset.*` server functions for a form."""
from django.forms import formset_factory from django.forms import formset_factory
from .schemas import ( from mizan.forms.schemas import (
FormsetSchema, FormsetSchema,
FormsetSubmitFail, FormsetSubmitFail,
FormsetSubmitPass, FormsetSubmitPass,
FormsetValidation, FormsetValidation,
) )
from .schema_utils import build_form_schema from mizan.forms.schema_utils import build_form_schema
from .validation_utils import build_formset_validation from mizan.forms.validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data from mizan.forms.formset_utils import forms_to_formset_post_data
from mizan_core.registry import register from mizan_core.registry import register
from mizan_core.client.function import ServerFunction from mizan_core.client.function import ServerFunction
formset_class = formset_factory(form_class) formset_class = formset_factory(form_class)
# Generate PascalCase name for schemas
pascal_name = "".join( pascal_name = "".join(
word.capitalize() word.capitalize()
for word in form_name.replace(".", "_").replace("-", "_").split("_") 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 # 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( FormsetSchemaInput = create_model(
f"{pascal_name}FormsetSchemaInput", f"{pascal_name}FormsetSchemaInput",
forms=(list[dict[str, Any]], []), forms=(list[dict[str, Any]], []),
@@ -542,7 +357,6 @@ def _register_formset_functions(
# Formset Validate Function # Formset Validate Function
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Generic dict input - form fields aren't available during __init_subclass__
FormsetValidateInput = create_model( FormsetValidateInput = create_model(
f"{pascal_name}FormsetValidateInput", f"{pascal_name}FormsetValidateInput",
forms=(list[dict[str, Any]], ...), forms=(list[dict[str, Any]], ...),
@@ -560,12 +374,12 @@ def _register_formset_functions(
def call(self, input) -> FormsetValidation: def call(self, input) -> FormsetValidation:
init_kwargs = form_class.get_init_kwargs(self.request) init_kwargs = form_class.get_init_kwargs(self.request)
# Input.forms is already a list of dicts
forms_data = input.forms forms_data = input.forms
formset_data = forms_to_formset_post_data(forms_data) formset_data = forms_to_formset_post_data(forms_data)
formset = formset_class(formset_data, form_kwargs=init_kwargs) formset = formset_class(formset_data, form_kwargs=init_kwargs)
# Every submitted row must validate; blank rows are not excused.
for form in formset: for form in formset:
form.empty_permitted = False form.empty_permitted = False
@@ -578,7 +392,6 @@ def _register_formset_functions(
# Formset Submit Function # Formset Submit Function
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Generic dict input - form fields aren't available during __init_subclass__
FormsetSubmitInput = create_model( FormsetSubmitInput = create_model(
f"{pascal_name}FormsetSubmitInput", f"{pascal_name}FormsetSubmitInput",
forms=(list[dict[str, Any]], ...), forms=(list[dict[str, Any]], ...),
@@ -598,12 +411,10 @@ def _register_formset_functions(
request = self.request request = self.request
init_kwargs = form_class.get_init_kwargs(request) init_kwargs = form_class.get_init_kwargs(request)
# Handle multipart vs JSON
if hasattr(request, "_mizan_form_data"): if hasattr(request, "_mizan_form_data"):
post_data = request._mizan_form_data post_data = request._mizan_form_data
files = request._mizan_form_files files = request._mizan_form_files
elif input and hasattr(input, "forms"): elif input and hasattr(input, "forms"):
# Input.forms is already a list of dicts
forms_data = input.forms forms_data = input.forms
post_data = forms_to_formset_post_data(forms_data) post_data = forms_to_formset_post_data(forms_data)
files = None files = None
@@ -620,9 +431,7 @@ def _register_formset_functions(
return FormsetSubmitPass(success=True) return FormsetSubmitPass(success=True)
validation = build_formset_validation(formset) validation = build_formset_validation(formset)
# Call failure handler on each form
for form in formset.forms: 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) return FormsetSubmitFail(success=False, errors=validation)
@@ -641,10 +450,8 @@ def register_form(
submit_handler: Any = None, submit_handler: Any = None,
) -> None: ) -> None:
""" """
Register a Django Form class as Mizan server functions. Register a plain Django Form as `{name}.schema`, `{name}.validate`, and —
when `submit_handler` is given — `{name}.submit`.
Creates and registers `{name}.schema`, `{name}.validate`, and
`{name}.submit` (if a submit_handler is provided).
""" """
from mizan_core.client.function import create_form_functions from mizan_core.client.function import create_form_functions
from mizan_core.registry import register from mizan_core.registry import register
@@ -660,10 +467,8 @@ def register_form(
def get_forms() -> dict[str, list]: def get_forms() -> dict[str, list]:
""" """
Group registered form-related functions by their form name. Group registered form-related functions by their form name, e.g.
`{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}`.
Returns a mapping like:
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
""" """
from mizan_core.registry import get_all_functions from mizan_core.registry import get_all_functions

View File

@@ -4,7 +4,7 @@ from typing import Any, Optional
from django import forms from django import forms
from django.forms import Field 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( def create_form_instance(
@@ -14,16 +14,14 @@ def create_form_instance(
**kwargs, **kwargs,
) -> forms.BaseForm: ) -> forms.BaseForm:
""" """
Create a form instance, gracefully handling kwargs that the form doesn't accept. Instantiate `form_class`, dropping kwargs its __init__ rejects.
Some Django forms (like allauth's) accept `request` in __init__, others don't. Django form __init__ signatures vary — some accept `request`, others do
This function tries with all kwargs first, then progressively removes kwargs not — so instantiation is retried with the offending kwarg removed until
that cause TypeErrors until instantiation succeeds. 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'] optional_kwargs = ['request', 'user', 'instance']
# Build init kwargs
init_kwargs = dict(kwargs) init_kwargs = dict(kwargs)
if data is not None: if data is not None:
init_kwargs['data'] = data init_kwargs['data'] = data
@@ -36,11 +34,9 @@ def create_form_instance(
except TypeError as e: except TypeError as e:
error_msg = str(e) error_msg = str(e)
# Check if it's an unexpected keyword argument error
if "unexpected keyword argument" not in error_msg: if "unexpected keyword argument" not in error_msg:
raise raise
# Find which kwarg caused the problem and remove it
removed = False removed = False
for kwarg in optional_kwargs: for kwarg in optional_kwargs:
if f"'{kwarg}'" in error_msg and kwarg in init_kwargs: if f"'{kwarg}'" in error_msg and kwarg in init_kwargs:
@@ -48,31 +44,28 @@ def create_form_instance(
removed = True removed = True
break break
# If we couldn't identify/remove the problematic kwarg, re-raise
if not removed: if not removed:
raise raise
def _get_choices(field: Field) -> Optional[list[FieldChoice]]: def _get_choices(field: Field) -> Optional[list[FieldChoice]]:
""" """
Extract choices from a field, handling ModelChoiceField properly. Extract a field's choices as JSON-serializable pairs. ModelChoiceField
ModelChoiceField returns ModelChoiceIteratorValue which is not JSON serializable. yields ModelChoiceIteratorValue, which has to be unwrapped via `.value`.
""" """
if not hasattr(field, "choices"): if not hasattr(field, "choices"):
return None return None
choices: list[FieldChoice] = [] choices: list[FieldChoice] = []
for raw_value, label in field.choices: for raw_value, label in field.choices:
value = getattr( value = getattr(raw_value, "value", raw_value)
raw_value, "value", raw_value
) # ModelChoiceIteratorValue -> .value
choices.append(FieldChoice(value=str(value), label=str(label))) choices.append(FieldChoice(value=str(value), label=str(label)))
return choices return choices
def _get_initial(value: Any) -> Any: 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: if value is None:
return None return None
if hasattr(value, "isoformat"): if hasattr(value, "isoformat"):
@@ -85,25 +78,15 @@ def _get_initial(value: Any) -> Any:
def _class_name_to_title(name: str) -> str: def _class_name_to_title(name: str) -> str:
""" """'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'."""
Convert a class name to a human-readable title.
e.g., 'LoginForm' -> 'Login', 'ResetPasswordForm' -> 'Reset Password'
"""
# Remove 'Form' suffix
name = re.sub(r"Form$", "", name) name = re.sub(r"Form$", "", name)
# Insert spaces before capital letters
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name) name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
return name return name
def _class_name_to_slug(name: str) -> str: def _class_name_to_slug(name: str) -> str:
""" """'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'."""
Convert a class name to a slug.
e.g., 'LoginForm' -> 'login', 'ResetPasswordForm' -> 'reset_password'
"""
# Remove 'Form' suffix
name = re.sub(r"Form$", "", name) 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) name = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
return name.lower() return name.lower()
@@ -114,48 +97,31 @@ def build_form_schema(
**kwargs, **kwargs,
) -> FormSchema: ) -> 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: Attributes on the form's inner `Meta` class — `form_name`, `title`,
`subtitle`, `submit_label`, `refetch_schema_on_validate`,
class MyForm(forms.Form): `live_validation`, `live_form_errors` — override the values otherwise
class Meta: derived from the class name.
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.
""" """
form = create_form_instance(form_class, data=data, **kwargs) form = create_form_instance(form_class, data=data, **kwargs)
# Extract metadata from form's Meta class
form_meta = getattr(form_class, "Meta", None) form_meta = getattr(form_class, "Meta", None)
# Get form name (used as identifier)
name = getattr(form_meta, "form_name", None) name = getattr(form_meta, "form_name", None)
if name is None: if name is None:
name = _class_name_to_slug(form_class.__name__) name = _class_name_to_slug(form_class.__name__)
# Get title (human-readable heading)
title = getattr(form_meta, "title", None) title = getattr(form_meta, "title", None)
if title is None: if title is None:
title = _class_name_to_title(form_class.__name__) title = _class_name_to_title(form_class.__name__)
# Get optional subtitle
subtitle = getattr(form_meta, "subtitle", None) subtitle = getattr(form_meta, "subtitle", None)
# Get submit button label
submit_label = getattr(form_meta, "submit_label", None) submit_label = getattr(form_meta, "submit_label", None)
if submit_label is None: if submit_label is None:
submit_label = "Submit" submit_label = "Submit"
# Build frontend behavior metadata
frontend_meta = FormMeta( frontend_meta = FormMeta(
refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False), refetch_schema_on_validate=getattr(form_meta, "refetch_schema_on_validate", False),
live_validation=getattr(form_meta, "live_validation", True), live_validation=getattr(form_meta, "live_validation", True),

View File

@@ -4,13 +4,13 @@ from django import forms
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.utils.datastructures import MultiValueDict from django.utils.datastructures import MultiValueDict
from .schemas import ( from mizan.forms.schemas import (
FieldError, FieldError,
FieldErrorList, FieldErrorList,
FormValidation, FormValidation,
FormsetValidation, FormsetValidation,
) )
from .schema_utils import create_form_instance from mizan.forms.schema_utils import create_form_instance
def validate_form_instance( def validate_form_instance(
@@ -19,12 +19,9 @@ def validate_form_instance(
files: MultiValueDict[str, UploadedFile] | None = None, files: MultiValueDict[str, UploadedFile] | None = None,
**kwargs: Any, **kwargs: Any,
) -> tuple[forms.BaseForm, FormValidation]: ) -> tuple[forms.BaseForm, FormValidation]:
""" """Build a form instance and return it alongside its structured field errors."""
Build a form instance and return (form, structured_validation_errors).
"""
form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs) form = create_form_instance(form_class, data=data, files=files, initial=data, **kwargs)
# Run validation
form.is_valid() form.is_valid()
validation = FormValidation( validation = FormValidation(
@@ -46,9 +43,7 @@ def validate_form_instance(
def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation: def build_formset_validation(formset: forms.BaseFormSet) -> FormsetValidation:
""" """Turn a Django formset's non-form and per-form errors into a FormsetValidation."""
Turn a Django formset into a FormsetValidation structure.
"""
return FormsetValidation( return FormsetValidation(
general=[str(e) if e else "" for e in formset.non_form_errors()], general=[str(e) if e else "" for e in formset.non_form_errors()],
per_form=[ per_form=[

View File

@@ -1,26 +1,15 @@
""" """
mizan.jwt - JWT authentication for server functions. JWT issuance and validation for mizan server functions.
Provides: `jwt_obtain` / `jwt_refresh` are server functions; importing
- Server functions for obtaining/refreshing JWT tokens `mizan.jwt.functions` is what registers them. The Ninja auth class
- JWT authentication utilities for validating tokens `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:
- 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.
""" """
# Server functions (import to register with @client decorator) from mizan.jwt.functions import jwt_obtain, jwt_refresh
from .functions import jwt_obtain, jwt_refresh
# Token utilities from mizan.jwt.tokens import (
from .tokens import (
create_token_pair, create_token_pair,
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
@@ -31,17 +20,12 @@ from .tokens import (
JWTUser, JWTUser,
) )
# Settings from mizan.jwt.settings import get_settings, JWTSettings
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
def __getattr__(name): def __getattr__(name):
if name in ("JWTAuth", "jwt_auth"): if name in ("JWTAuth", "jwt_auth"):
from .security import JWTAuth, jwt_auth from mizan.jwt.security import JWTAuth, jwt_auth
globals()["JWTAuth"] = JWTAuth globals()["JWTAuth"] = JWTAuth
globals()["jwt_auth"] = jwt_auth globals()["jwt_auth"] = jwt_auth

View File

@@ -1,64 +1,33 @@
""" """
Django Ninja Security Classes for JWT Authentication Django Ninja security class for JWT bearer authentication, usable as
`@api.get(..., auth=jwt_auth)` or in an API-wide `auth=[...]` list.
Provides authentication classes that can be used with Django Ninja's
auth parameter to protect API endpoints.
""" """
from django.http import HttpRequest from django.http import HttpRequest
from ninja.security import HttpBearer from ninja.security import HttpBearer
from .tokens import decode_token, JWTUser from mizan.jwt.tokens import decode_token, JWTUser
class JWTAuth(HttpBearer): class JWTAuth(HttpBearer):
""" """
JWT Bearer token authentication for Django Ninja. Reads `Authorization: Bearer <access_token>` and sets `request.user` to a
JWTUser built from the token claims. No database query is made, so the
Usage: resulting user carries only id, is_staff, and is_superuser.
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)
""" """
def authenticate(self, request: HttpRequest, token: str): def authenticate(self, request: HttpRequest, token: str):
""" """Return a JWTUser for a valid access token, or None to fail auth."""
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
payload = decode_token(token, expected_type="access") payload = decode_token(token, expected_type="access")
if payload is None: if payload is None:
return None return None
# Create JWTUser from token claims - NO DATABASE QUERY
jwt_user = JWTUser(payload) jwt_user = JWTUser(payload)
# Set request.user for compatibility with code expecting it
request.user = jwt_user request.user = jwt_user
return jwt_user return jwt_user
# Singleton instance for convenience
jwt_auth = JWTAuth() jwt_auth = JWTAuth()

View File

@@ -1,17 +1,20 @@
""" """
JWT Token Creation and Validation JWT creation and validation over PyJWT.
Uses PyJWT directly - no allauth dependency. Every token carries the Django session key in `sid`; `validate_session`
Tokens are tied to Django sessions for immediate revocation on logout. re-checks that the session still exists, which is what makes logout revoke
outstanding tokens immediately.
""" """
import logging
import time import time
from typing import NamedTuple from typing import NamedTuple
import jwt 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): class TokenPair(NamedTuple):
@@ -34,13 +37,9 @@ class TokenPayload(NamedTuple):
class JWTUser: class JWTUser:
""" """
Minimal user object created from JWT claims. 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
Used as request.user for JWT-authenticated requests. else about the user has to be queried explicitly by the caller.
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)
""" """
def __init__(self, payload: TokenPayload): def __init__(self, payload: TokenPayload):
@@ -50,7 +49,7 @@ class JWTUser:
self.is_superuser = payload.is_superuser self.is_superuser = payload.is_superuser
self.is_authenticated = True self.is_authenticated = True
self.is_anonymous = False 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): def __str__(self):
return f"JWTUser(id={self.id})" return f"JWTUser(id={self.id})"
@@ -66,18 +65,7 @@ def create_access_token(
is_staff: bool = False, is_staff: bool = False,
is_superuser: bool = False, is_superuser: bool = False,
) -> str: ) -> str:
""" """Create a short-lived access token."""
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
"""
settings = get_settings() settings = get_settings()
now = int(time.time()) now = int(time.time())
@@ -105,18 +93,7 @@ def create_refresh_token(
is_staff: bool = False, is_staff: bool = False,
is_superuser: bool = False, is_superuser: bool = False,
) -> str: ) -> str:
""" """Create a longer-lived refresh token."""
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
"""
settings = get_settings() settings = get_settings()
now = int(time.time()) 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. Decode and validate a JWT, returning None when it is malformed, expired,
or not of `expected_type`.
Returns None if:
- Token is invalid or expired
- Token type doesn't match expected_type (if specified)
""" """
settings = get_settings() settings = get_settings()
@@ -173,11 +147,18 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
settings.public_key, settings.public_key,
algorithms=[settings.algorithm], 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 return None
# Validate token type if specified
if expected_type and payload.get("type") != expected_type: 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 None
return TokenPayload( return TokenPayload(
@@ -193,10 +174,8 @@ def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
def validate_session(session_key: str) -> bool: def validate_session(session_key: str) -> bool:
""" """
Check if a session is still valid (exists and not expired). Report whether the Django session backing a token still exists. Returns
True unconditionally when session validation is switched off in settings.
This is the key to immediate logout revocation - if the session
is destroyed, tokens tied to it become invalid.
""" """
from importlib import import_module from importlib import import_module
@@ -207,36 +186,30 @@ def validate_session(session_key: str) -> bool:
if not jwt_settings.validate_session: if not jwt_settings.validate_session:
return True return True
# Use the configured session engine
engine = import_module(django_settings.SESSION_ENGINE) engine = import_module(django_settings.SESSION_ENGINE)
SessionStore = engine.SessionStore SessionStore = engine.SessionStore
# Try to load the session
session = SessionStore(session_key=session_key) session = SessionStore(session_key=session_key)
# Check if session exists and is not empty # exists() reads the backend directly; load() would silently hand back an
# exists() is more reliable than checking load() result # empty session for a missing key.
return session.exists(session_key) return session.exists(session_key)
def refresh_tokens(refresh_token: str) -> TokenPair | None: def refresh_tokens(refresh_token: str) -> TokenPair | None:
""" """
Use a refresh token to obtain new tokens. Exchange a refresh token for a fresh pair carrying the same claims.
Returns None when the token is invalid or its session is gone.
Returns None if:
- Refresh token is invalid or expired
- Associated session no longer exists
""" """
payload = decode_token(refresh_token, expected_type="refresh") payload = decode_token(refresh_token, expected_type="refresh")
if payload is None: if payload is None:
return None return None
# Validate the session still exists
if not validate_session(payload.session_key): if not validate_session(payload.session_key):
logger.debug("JWT refresh rejected: session %r no longer exists", payload.session_key)
return None return None
# Issue new token pair with same claims
return create_token_pair( return create_token_pair(
payload.user_id, payload.user_id,
payload.session_key, payload.session_key,

View File

@@ -1,11 +1,5 @@
""" """
Export channels schema as OpenAPI JSON for TypeScript generation. Writes the channels schema to stdout as OpenAPI JSON.
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
""" """
import json import json
@@ -29,6 +23,7 @@ class Command(BaseCommand):
schema = get_channels_openapi_schema() schema = get_channels_openapi_schema()
# indent=0 is not compact in json.dumps; None is.
indent = options["indent"] if options["indent"] > 0 else None indent = options["indent"] if options["indent"] > 0 else None
output = json.dumps(schema, indent=indent) output = json.dumps(schema, indent=indent)

View File

@@ -1,14 +1,4 @@
""" """Management command emitting the edge cache manifest as JSON."""
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
"""
import json import json
from pathlib import Path from pathlib import Path

View File

@@ -1,18 +1,13 @@
""" """
Mizan IR (KDL) export — Django management command. Writes the canonical Mizan IR as KDL to stdout, which the Rust codegen binary
consumes. Nothing else in this command may write to stdout.
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.
""" """
from __future__ import annotations from __future__ import annotations
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from mizan.setup.discovery import mizan_clients
from mizan_core.ir import build_ir from mizan_core.ir import build_ir
@@ -20,9 +15,6 @@ class Command(BaseCommand):
help = "Export every registered @client function as Mizan IR (KDL)." help = "Export every registered @client function as Mizan IR (KDL)."
def handle(self, *args, **options) -> None: def handle(self, *args, **options) -> None:
# Load every project-side @client function so the registry is # Discovery populates the registry build_ir() reads.
# populated before we emit. Conventionally apps/*/clients.py. mizan_clients()
from mizan.setup.discovery import mizan_clients
mizan_clients("apps")
self.stdout.write(build_ir(), ending="") self.stdout.write(build_ir(), ending="")

View File

@@ -1,12 +1,7 @@
""" """
mizan.setup - Django integration helpers. Curated Django-side surface: registration, lookup, discovery, and settings
helpers, re-exported from `mizan_core.registry`, `mizan.channels`,
The function/composition registry now lives in `mizan_core.registry`. `mizan.forms`, and this package's own modules.
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.
""" """
from mizan_core.registry import ( from mizan_core.registry import (
@@ -35,12 +30,13 @@ from mizan.forms import (
get_forms, get_forms,
) )
from .discovery import ( from mizan.setup.discovery import (
discover_apps_roots,
mizan_clients, mizan_clients,
mizan_module, mizan_module,
) )
from .settings import ( from mizan.setup.settings import (
mizanSettings, mizanSettings,
get_settings, get_settings,
clear_settings_cache, clear_settings_cache,
@@ -67,6 +63,7 @@ __all__ = [
"validate_registry", "validate_registry",
"clear_registry", "clear_registry",
# Discovery # Discovery
"discover_apps_roots",
"mizan_clients", "mizan_clients",
"mizan_module", "mizan_module",
# Settings # Settings

View File

@@ -1,7 +1,15 @@
import logging import logging
from pathlib import Path
from typing import Any 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.registry import register, get_function
from mizan_core.client.function import ServerFunction from mizan_core.client.function import ServerFunction
@@ -9,22 +17,20 @@ from mizan_core.client.function import ServerFunction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class _RegisterServerFunctions: class _RegisterServerFunctions(DjangoAppVisitorHandler):
def on_module( def on_module(
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]] self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
) -> None: ) -> None:
for name, member in members: for name, member in members:
# Register ServerFunction subclasses
if ( if (
isinstance(member, type) isinstance(member, type)
and issubclass(member, ServerFunction) and issubclass(member, ServerFunction)
and member is not ServerFunction and member is not ServerFunction
and hasattr(member, "__name__") and hasattr(member, "__name__")
): ):
# Use the function name as registration name
fn_name = getattr(member, "name", None) or member.__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: if get_function(fn_name) is member:
continue continue
@@ -40,14 +46,48 @@ class _RegisterServerFunctions:
) )
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root def discover_apps_roots() -> list[str]:
def mizan_clients(apps_root: str, layer: str = "clients") -> None: """
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) Dotted package prefixes the project's own apps sit under, relative to
visitor.visit(_RegisterServerFunctions()) 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: 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) members = get_members(module_path)
handler = _RegisterServerFunctions() handler = _RegisterServerFunctions()
handler.on_module("", [], members) handler.on_module("", [], members)

View File

@@ -1,25 +1,8 @@
""" """
mizan.ssr — Server-side rendering via Bun subprocess. 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.
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.
""" """
from .backend import MizanTemplates from mizan.ssr.backend import MizanTemplates
__all__ = ["MizanTemplates"] __all__ = ["MizanTemplates"]

View File

@@ -1,17 +1,7 @@
""" """
Mizan SSR Template Backend — Django template engine that renders React via Bun. 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
TEMPLATES = [ the worker script; `OPTIONS['timeout']` bounds a single render.
{
'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'})
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,7 +13,7 @@ from django.template import TemplateDoesNotExist
from django.template.backends.base import BaseEngine from django.template.backends.base import BaseEngine
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from .bridge import SSRBridge from mizan.ssr.bridge import SSRBridge
class MizanTemplate: class MizanTemplate:
@@ -38,12 +28,12 @@ class MizanTemplate:
import json as _json import json as _json
props = dict(context) if context else {} props = dict(context) if context else {}
# Neither is JSON-serializable, and neither belongs in client hydration.
props.pop("request", None) props.pop("request", None)
props.pop("csrf_token", None) props.pop("csrf_token", None)
result = self._bridge.render(self.file_path, props) 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) hydration_json = _json.dumps(props, sort_keys=True, default=str)
return mark_safe( return mark_safe(
@@ -54,10 +44,12 @@ class MizanTemplate:
class MizanTemplates(BaseEngine): 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. A template is a module the Bun worker imports by path, so a source string
Same model as Django's built-in template engines. names nothing this engine can render — `from_string` is left to BaseEngine,
which rejects it.
""" """
def __init__(self, params: dict[str, Any]) -> None: def __init__(self, params: dict[str, Any]) -> None:
@@ -93,8 +85,3 @@ class MizanTemplates(BaseEngine):
self.get_bridge(), self.get_bridge(),
) )
raise TemplateDoesNotExist(template_name) raise TemplateDoesNotExist(template_name)
def from_string(self, template_code: str) -> MizanTemplate:
raise TemplateDoesNotExist(
"MizanTemplates renders .tsx files, not template strings."
)

View File

@@ -1,13 +1,11 @@
""" """
SSR Bridge — Manages a persistent Bun subprocess for React rendering. Persistent Bun subprocess speaking newline-delimited JSON-RPC over
stdin/stdout.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}} Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
Response: {"id": 1, "html": "<div>...</div>"} Response: {"id": 1, "html": "<div>...</div>"}
The subprocess stays alive across requests. It is started on first use Message id 0 is reserved for the worker's unsolicited ready signal.
and restarted automatically if it crashes.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,10 +29,9 @@ class RenderResult:
class SSRBridge: class SSRBridge:
""" """
Manages a persistent Bun subprocess for server-side rendering. Owns the Bun subprocess. Thread-safe: concurrent render() callers are
matched to their response by message id, and stdin writes are serialized
Thread-safe. Multiple Django workers can call render() concurrently. so requests never interleave mid-line.
Request-response matching via message IDs.
""" """
def __init__(self, worker_path: str, timeout: float = 5.0) -> None: def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
@@ -42,18 +39,17 @@ class SSRBridge:
self._timeout = timeout self._timeout = timeout
self._proc: subprocess.Popen | None = None self._proc: subprocess.Popen | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._write_lock = threading.Lock() # Serializes stdin writes self._write_lock = threading.Lock()
self._counter = 0 self._counter = 0
self._pending: dict[int, threading.Event] = {} self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {} self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None self._reader_thread: threading.Thread | None = None
self._ready = threading.Event() self._ready = threading.Event()
# Ensure cleanup on process exit
atexit.register(self.shutdown) atexit.register(self.shutdown)
def _ensure_running(self) -> None: 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: if self._proc is not None and self._proc.poll() is None:
return return
@@ -73,7 +69,6 @@ class SSRBridge:
) )
self._reader_thread.start() self._reader_thread.start()
# Wait for the "ready" signal from the worker
if not self._ready.wait(timeout=self._timeout): if not self._ready.wait(timeout=self._timeout):
logger.error("Bun SSR worker failed to start within %ss", self._timeout) logger.error("Bun SSR worker failed to start within %ss", self._timeout)
self.shutdown() self.shutdown()
@@ -82,7 +77,7 @@ class SSRBridge:
logger.info("Bun SSR worker started (pid %s)", self._proc.pid) logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
def _read_responses(self) -> None: 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: try:
for line in self._proc.stdout: for line in self._proc.stdout:
if isinstance(line, bytes): if isinstance(line, bytes):
@@ -99,7 +94,6 @@ class SSRBridge:
msg_id = msg.get("id") msg_id = msg.get("id")
# Ready signal (id=0)
if msg_id == 0 and msg.get("ready"): if msg_id == 0 and msg.get("ready"):
self._ready.set() self._ready.set()
continue continue
@@ -112,18 +106,10 @@ class SSRBridge:
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult: 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: Raises TimeoutError past the configured timeout and RuntimeError when
file: Absolute path to the .tsx/.jsx file to render. the worker reports a render error or its pipe is broken.
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.
""" """
with self._lock: with self._lock:
self._ensure_running() self._ensure_running()
@@ -139,7 +125,6 @@ class SSRBridge:
"params": {"file": file, "props": props or {}}, "params": {"file": file, "props": props or {}},
}) + "\n" }) + "\n"
# Serialize stdin writes to prevent interleaving from concurrent threads
with self._write_lock: with self._write_lock:
try: try:
self._proc.stdin.write(request.encode("utf-8")) self._proc.stdin.write(request.encode("utf-8"))
@@ -163,19 +148,24 @@ class SSRBridge:
return RenderResult(html=result["html"]) return RenderResult(html=result["html"])
def shutdown(self) -> None: def shutdown(self) -> None:
"""Stop the Bun subprocess.""" """Stop the Bun subprocess, escalating to kill if terminate does not land."""
if self._proc is not None: if self._proc is None:
return
try: try:
self._proc.stdin.close() self._proc.stdin.close()
except Exception: except OSError:
pass logger.warning("Closing SSR worker stdin failed", exc_info=True)
try: try:
self._proc.terminate() self._proc.terminate()
self._proc.wait(timeout=3) self._proc.wait(timeout=3)
except Exception: except (OSError, subprocess.TimeoutExpired):
logger.warning("SSR worker did not terminate; killing it", exc_info=True)
try: try:
self._proc.kill() self._proc.kill()
except Exception: except OSError:
pass logger.warning("Killing SSR worker failed", exc_info=True)
self._proc = None self._proc = None
logger.info("Bun SSR worker stopped") logger.info("Bun SSR worker stopped")

View File

@@ -0,0 +1,3 @@
export default function Hello({ name }: { name?: string }) {
return <div data-mizan-component="Hello">Hello, {name}!</div>
}

View File

@@ -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. These measure rather than assert on timing; each one still checks that the
Includes realistic scenarios with ORM queries. function under measurement returned the right answer. Timings printed here are
only meaningful when the module is run in isolation.
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.
""" """
import asyncio
import json import json
import statistics import statistics
import time import time
from typing import Any from typing import Any
from unittest.mock import MagicMock, AsyncMock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db import connection
from django.http import HttpRequest 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 pydantic import BaseModel
from mizan.client.executor import FunctionResult, execute_function, function_call_view from mizan.client.executor import FunctionResult, execute_function, function_call_view
@@ -141,9 +133,10 @@ def setup_benchmark_functions():
class ProtocolBenchmark(TransactionTestCase): 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 # Number of iterations for each benchmark
@@ -157,19 +150,17 @@ class ProtocolBenchmark(TransactionTestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
# Create test users for ORM benchmarks
self._create_test_users() self._create_test_users()
def _create_test_users(self): def _create_test_users(self):
"""Create test users for benchmarks.""" """Create 100 users, 90% of them active and 5 of them staff."""
# Create 100 test users
users = [] users = []
for i in range(100): for i in range(100):
users.append( users.append(
User( User(
email=f"bench{i}@example.com", email=f"bench{i}@example.com",
is_active=i % 10 != 0, # 90% active is_active=i % 10 != 0,
is_staff=i < 5, # 5 staff is_staff=i < 5,
) )
) )
User.objects.bulk_create(users, ignore_conflicts=True) User.objects.bulk_create(users, ignore_conflicts=True)
@@ -190,11 +181,7 @@ class ProtocolBenchmark(TransactionTestCase):
return request return request
def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict: def _benchmark_executor(self, fn_name: str, args: dict, label: str) -> dict:
""" """Time direct executor calls and return timing statistics."""
Benchmark direct executor calls (simulates WebSocket RPC).
Returns timing statistics.
"""
request = self._make_request() request = self._make_request()
times = [] times = []
@@ -212,11 +199,7 @@ class ProtocolBenchmark(TransactionTestCase):
return self._compute_stats(times, f"Executor ({label})") return self._compute_stats(times, f"Executor ({label})")
def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict: def _benchmark_http(self, fn_name: str, args: dict, label: str) -> dict:
""" """Time HTTP view calls and return timing statistics."""
Benchmark HTTP view calls.
Returns timing statistics.
"""
times = [] times = []
# Warmup # Warmup
@@ -366,17 +349,16 @@ class ProtocolBenchmark(TransactionTestCase):
self.assertIn("bench", user["email"].lower()) self.assertIn("bench", user["email"].lower())
def test_summary(self): def test_summary(self):
"""Print summary of all benchmarks.""" """Print the legend for the preceding benchmark tables."""
print("\n\n" + "=" * 80) print("\n\n" + "=" * 80)
print("BENCHMARK SUMMARY") print("BENCHMARK SUMMARY")
print("=" * 80) print("=" * 80)
print(f"Iterations per benchmark: {self.ITERATIONS}") print(f"Iterations per benchmark: {self.ITERATIONS}")
print(f"Warmup iterations: {self.WARMUP}") print(f"Warmup iterations: {self.WARMUP}")
print("\nKey findings:") print("\nColumns:")
print("- 'Executor' simulates WebSocket RPC (direct function call)") print("- 'Executor' calls execute_function directly")
print("- 'HTTP' measures full request/response cycle") print("- 'HTTP' calls function_call_view, so it includes JSON parsing,")
print("- HTTP overhead includes: JSON parsing, CSRF, view dispatch") print(" CSRF handling, and view dispatch")
print("- For I/O-bound operations, protocol overhead is negligible")
print("=" * 80) print("=" * 80)
# Verify bench_simple still produces correct output after all benchmarks # Verify bench_simple still produces correct output after all benchmarks
@@ -392,11 +374,7 @@ class ProtocolBenchmark(TransactionTestCase):
class ThroughputBenchmark(TransactionTestCase): class ThroughputBenchmark(TransactionTestCase):
""" """Requests per second for the executor path versus the HTTP view path."""
Measure requests per second (throughput) for server functions.
Tests both sequential and concurrent scenarios.
"""
DURATION_SECONDS = 2 # How long to run each throughput test DURATION_SECONDS = 2 # How long to run each throughput test
@@ -410,7 +388,7 @@ class ThroughputBenchmark(TransactionTestCase):
self._create_test_users() self._create_test_users()
def _create_test_users(self): def _create_test_users(self):
"""Create test users for benchmarks.""" """Create 100 users, 90% of them active and 5 of them staff."""
users = [] users = []
for i in range(100): for i in range(100):
users.append( users.append(
@@ -548,16 +526,14 @@ class ThroughputBenchmark(TransactionTestCase):
self.assertGreaterEqual(result.data["total_users"], 0) self.assertGreaterEqual(result.data["total_users"], 0)
def test_throughput_summary(self): def test_throughput_summary(self):
"""Print throughput summary.""" """Print the measurement conditions for the preceding throughput tests."""
print("\n\n" + "=" * 80) print("\n\n" + "=" * 80)
print("THROUGHPUT SUMMARY") print("THROUGHPUT SUMMARY")
print("=" * 80) print("=" * 80)
print(f"Test duration: {self.DURATION_SECONDS}s per scenario") print(f"Test duration: {self.DURATION_SECONDS}s per scenario")
print("\nNotes:") print("\nConditions:")
print("- These are single-threaded sequential measurements") print("- Single-threaded and sequential")
print("- Real throughput scales with worker processes (gunicorn -w N)") print("- SQLite in-memory database")
print("- Database queries are the bottleneck, not protocol overhead")
print("- Async workers (uvicorn) can handle more concurrent connections")
print("=" * 80) print("=" * 80)
# Verify bench_simple still produces correct output after all throughput tests # Verify bench_simple still produces correct output after all throughput tests

View File

@@ -5,11 +5,10 @@ Tests for mizan.channels module.
import json import json
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model
from pydantic import BaseModel from pydantic import BaseModel
from mizan.channels import ( from mizan.channels import (
ReactChannel, Channel,
register, register,
get_channel, get_channel,
get_registered_channels, get_registered_channels,
@@ -18,9 +17,6 @@ from mizan.channels import (
) )
User = get_user_model()
# ============================================================================= # =============================================================================
# Test Fixtures # Test Fixtures
# ============================================================================= # =============================================================================
@@ -42,52 +38,47 @@ class MockAnonymousUser:
# ============================================================================= # =============================================================================
# ReactChannel Base Class Tests # Channel Base Class Tests
# ============================================================================= # =============================================================================
class ReactChannelBaseTests(TestCase): class ChannelBaseTests(TestCase):
"""Tests for ReactChannel base class.""" """Tests for Channel base class."""
def test_react_channel_default_class_vars(self): def test_channel_default_class_vars(self):
"""ReactChannel should have None defaults for nested classes.""" """Channel should have None defaults for nested classes."""
self.assertIsNone(ReactChannel.Params) self.assertIsNone(Channel.Params)
self.assertIsNone(ReactChannel.ReactMessage) self.assertIsNone(Channel.ClientMessage)
self.assertIsNone(ReactChannel.DjangoMessage) self.assertIsNone(Channel.ServerMessage)
def test_react_channel_requires_authorize_override(self): def test_channel_requires_authorize_override(self):
"""ReactChannel subclass must override authorize().""" """A subclass without authorize() cannot be instantiated."""
class IncompleteChannel(ReactChannel): class NoAuthorizeChannel(Channel):
pass def group(self, params=None):
return "test"
channel = IncompleteChannel() with self.assertRaises(TypeError) as ctx:
channel.user = MockUser() NoAuthorizeChannel()
with self.assertRaises(NotImplementedError) as ctx: self.assertIn("authorize", str(ctx.exception))
channel.authorize()
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): class NoGroupChannel(Channel):
"""ReactChannel subclass must override group()."""
class IncompleteChannel(ReactChannel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
channel = IncompleteChannel() with self.assertRaises(TypeError) as ctx:
channel.user = MockUser() NoGroupChannel()
with self.assertRaises(NotImplementedError) as ctx: self.assertIn("group", str(ctx.exception))
channel.group()
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): class BasicChannel(Channel):
"""ReactChannel.receive() should return None by default."""
class BasicChannel(ReactChannel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -99,10 +90,10 @@ class ReactChannelBaseTests(TestCase):
self.assertIsNone(result) self.assertIsNone(result)
def test_react_channel_init_creates_empty_groups(self): def test_channel_init_creates_empty_groups(self):
"""ReactChannel.__init__() should create empty _groups set.""" """Channel.__init__() should create empty _groups set."""
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -126,7 +117,7 @@ class TypedMessagesTests(TestCase):
def test_channel_with_params(self): def test_channel_with_params(self):
"""Channel should accept Params Pydantic model.""" """Channel should accept Params Pydantic model."""
class ParamsChannel(ReactChannel): class ParamsChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
limit: int = 10 limit: int = 10
@@ -139,16 +130,15 @@ class TypedMessagesTests(TestCase):
self.assertIsNotNone(ParamsChannel.Params) self.assertIsNotNone(ParamsChannel.Params)
# Test params model
params = ParamsChannel.Params(room="general") params = ParamsChannel.Params(room="general")
self.assertEqual(params.room, "general") self.assertEqual(params.room, "general")
self.assertEqual(params.limit, 10) self.assertEqual(params.limit, 10)
def test_channel_with_react_message(self): def test_channel_with_client_message(self):
"""Channel should accept ReactMessage Pydantic model.""" """Channel should accept ClientMessage Pydantic model."""
class MessageChannel(ReactChannel): class MessageChannel(Channel):
class ReactMessage(BaseModel): class ClientMessage(BaseModel):
text: str text: str
timestamp: int timestamp: int
@@ -158,18 +148,17 @@ class TypedMessagesTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "messages" return "messages"
self.assertIsNotNone(MessageChannel.ReactMessage) self.assertIsNotNone(MessageChannel.ClientMessage)
# Test message model msg = MessageChannel.ClientMessage(text="Hello", timestamp=12345)
msg = MessageChannel.ReactMessage(text="Hello", timestamp=12345)
self.assertEqual(msg.text, "Hello") self.assertEqual(msg.text, "Hello")
self.assertEqual(msg.timestamp, 12345) self.assertEqual(msg.timestamp, 12345)
def test_channel_with_django_message(self): def test_channel_with_server_message(self):
"""Channel should accept DjangoMessage Pydantic model.""" """Channel should accept ServerMessage Pydantic model."""
class BroadcastChannel(ReactChannel): class BroadcastChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
user: str user: str
text: str text: str
created_at: str created_at: str
@@ -180,10 +169,9 @@ class TypedMessagesTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "broadcast" return "broadcast"
self.assertIsNotNone(BroadcastChannel.DjangoMessage) self.assertIsNotNone(BroadcastChannel.ServerMessage)
# Test message model msg = BroadcastChannel.ServerMessage(
msg = BroadcastChannel.DjangoMessage(
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z" user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
) )
self.assertEqual(msg.user, "john") self.assertEqual(msg.user, "john")
@@ -192,14 +180,14 @@ class TypedMessagesTests(TestCase):
def test_channel_receive_with_typed_messages(self): def test_channel_receive_with_typed_messages(self):
"""Channel.receive() should work with typed messages.""" """Channel.receive() should work with typed messages."""
class ChatChannel(ReactChannel): class ChatChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
class ReactMessage(BaseModel): class ClientMessage(BaseModel):
text: str text: str
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
user: str user: str
text: str text: str
@@ -210,17 +198,17 @@ class TypedMessagesTests(TestCase):
return f"chat_{params.room}" return f"chat_{params.room}"
def receive(self, params, msg): 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 = ChatChannel()
channel.user = MockUser(email="test@example.com") channel.user = MockUser(email="test@example.com")
params = ChatChannel.Params(room="general") params = ChatChannel.Params(room="general")
incoming = ChatChannel.ReactMessage(text="Hello!") incoming = ChatChannel.ClientMessage(text="Hello!")
result = channel.receive(params, incoming) 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.user, "test@example.com")
self.assertEqual(result.text, "Hello!") self.assertEqual(result.text, "Hello!")
@@ -243,7 +231,7 @@ class RegistrationTests(TestCase):
def test_register_adds_to_registry(self): def test_register_adds_to_registry(self):
"""register() should add channel to registry.""" """register() should add channel to registry."""
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -255,17 +243,31 @@ class RegistrationTests(TestCase):
self.assertIn("test-channel", _registry) self.assertIn("test-channel", _registry)
self.assertEqual(_registry["test-channel"], TestChannel) self.assertEqual(_registry["test-channel"], TestChannel)
def test_register_duplicate_raises(self): def test_register_sets_registered_name(self):
"""register() should raise on duplicate name.""" """register() should stamp the wire name onto the class."""
class Channel1(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
def group(self, params=None): def group(self, params=None):
return "test" 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): def authorize(self, params=None):
return True return True
@@ -279,21 +281,10 @@ class RegistrationTests(TestCase):
self.assertIn("already registered", str(ctx.exception)) 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): def test_get_channel_returns_registered(self):
"""get_channel() should return registered channel.""" """get_channel() should return registered channel."""
class MyChannel(ReactChannel): class MyChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -315,7 +306,7 @@ class RegistrationTests(TestCase):
def test_get_registered_channels_returns_copy(self): def test_get_registered_channels_returns_copy(self):
"""get_registered_channels() should return a copy of registry.""" """get_registered_channels() should return a copy of registry."""
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -326,7 +317,6 @@ class RegistrationTests(TestCase):
result = get_registered_channels() result = get_registered_channels()
# Modifying result shouldn't affect original
result["modified"] = "test" result["modified"] = "test"
self.assertIn("copy-test", _registry) self.assertIn("copy-test", _registry)
@@ -360,7 +350,7 @@ class SchemaExportTests(TestCase):
def test_get_channels_schema_with_basic_channel(self): def test_get_channels_schema_with_basic_channel(self):
"""get_channels_schema() should include basic channel info.""" """get_channels_schema() should include basic channel info."""
class BasicChannel(ReactChannel): class BasicChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -376,13 +366,13 @@ class SchemaExportTests(TestCase):
self.assertEqual(channel_schema["name"], "basic") self.assertEqual(channel_schema["name"], "basic")
self.assertIsNone(channel_schema["params"]) self.assertIsNone(channel_schema["params"])
self.assertIsNone(channel_schema["reactMessage"]) self.assertIsNone(channel_schema["clientMessage"])
self.assertIsNone(channel_schema["djangoMessage"]) self.assertIsNone(channel_schema["serverMessage"])
def test_get_channels_schema_with_params(self): def test_get_channels_schema_with_params(self):
"""get_channels_schema() should include params schema.""" """get_channels_schema() should include params schema."""
class ParamsChannel(ReactChannel): class ParamsChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
limit: int = 50 limit: int = 50
@@ -407,14 +397,14 @@ class SchemaExportTests(TestCase):
def test_get_channels_schema_with_messages(self): def test_get_channels_schema_with_messages(self):
"""get_channels_schema() should include message schemas.""" """get_channels_schema() should include message schemas."""
class FullChannel(ReactChannel): class FullChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
channel_id: int channel_id: int
class ReactMessage(BaseModel): class ClientMessage(BaseModel):
text: str text: str
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
user: str user: str
text: str text: str
timestamp: str timestamp: str
@@ -431,24 +421,21 @@ class SchemaExportTests(TestCase):
channel_schema = schema["channels"]["full-channel"] channel_schema = schema["channels"]["full-channel"]
# Check params
self.assertIsNotNone(channel_schema["params"]) self.assertIsNotNone(channel_schema["params"])
self.assertIn("channel_id", channel_schema["params"]["properties"]) self.assertIn("channel_id", channel_schema["params"]["properties"])
# Check ReactMessage self.assertIsNotNone(channel_schema["clientMessage"])
self.assertIsNotNone(channel_schema["reactMessage"]) self.assertIn("text", channel_schema["clientMessage"]["properties"])
self.assertIn("text", channel_schema["reactMessage"]["properties"])
# Check DjangoMessage self.assertIsNotNone(channel_schema["serverMessage"])
self.assertIsNotNone(channel_schema["djangoMessage"]) self.assertIn("user", channel_schema["serverMessage"]["properties"])
self.assertIn("user", channel_schema["djangoMessage"]["properties"]) self.assertIn("text", channel_schema["serverMessage"]["properties"])
self.assertIn("text", channel_schema["djangoMessage"]["properties"]) self.assertIn("timestamp", channel_schema["serverMessage"]["properties"])
self.assertIn("timestamp", channel_schema["djangoMessage"]["properties"])
def test_get_channels_schema_multiple_channels(self): def test_get_channels_schema_multiple_channels(self):
"""get_channels_schema() should include all registered channels.""" """get_channels_schema() should include all registered channels."""
class Channel1(ReactChannel): class Channel1(Channel):
class Params(BaseModel): class Params(BaseModel):
id: int id: int
@@ -458,7 +445,7 @@ class SchemaExportTests(TestCase):
def group(self, params): def group(self, params):
return f"c1_{params.id}" return f"c1_{params.id}"
class Channel2(ReactChannel): class Channel2(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -473,13 +460,117 @@ class SchemaExportTests(TestCase):
self.assertIn("channel-one", schema["channels"]) self.assertIn("channel-one", schema["channels"])
self.assertIn("channel-two", schema["channels"]) self.assertIn("channel-two", schema["channels"])
# Channel 1 has params
self.assertIsNotNone(schema["channels"]["channel-one"]["params"]) self.assertIsNotNone(schema["channels"]["channel-one"]["params"])
# Channel 2 has no params
self.assertIsNone(schema["channels"]["channel-two"]["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 # Authorization Tests
# ============================================================================= # =============================================================================
@@ -491,7 +582,7 @@ class AuthorizationTests(TestCase):
def test_authorize_with_authenticated_user(self): def test_authorize_with_authenticated_user(self):
"""authorize() should work with authenticated users.""" """authorize() should work with authenticated users."""
class AuthChannel(ReactChannel): class AuthChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return self.user.is_authenticated return self.user.is_authenticated
@@ -506,7 +597,7 @@ class AuthorizationTests(TestCase):
def test_authorize_with_anonymous_user(self): def test_authorize_with_anonymous_user(self):
"""authorize() should work with anonymous users.""" """authorize() should work with anonymous users."""
class AuthChannel(ReactChannel): class AuthChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return self.user.is_authenticated return self.user.is_authenticated
@@ -521,7 +612,7 @@ class AuthorizationTests(TestCase):
def test_authorize_with_params(self): def test_authorize_with_params(self):
"""authorize() should have access to params.""" """authorize() should have access to params."""
class RoomChannel(ReactChannel): class RoomChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
@@ -553,7 +644,7 @@ class GroupTests(TestCase):
def test_group_returns_string(self): def test_group_returns_string(self):
"""group() should return a string group name.""" """group() should return a string group name."""
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -567,7 +658,7 @@ class GroupTests(TestCase):
def test_group_with_params(self): def test_group_with_params(self):
"""group() should use params for dynamic group names.""" """group() should use params for dynamic group names."""
class RoomChannel(ReactChannel): class RoomChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room_id: int room_id: int
@@ -598,7 +689,7 @@ class AsyncMethodsTests(TestCase):
"""_join_group() should add group to _groups set.""" """_join_group() should add group to _groups set."""
import asyncio import asyncio
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -624,7 +715,7 @@ class AsyncMethodsTests(TestCase):
"""_leave_group() should remove group from _groups set.""" """_leave_group() should remove group from _groups set."""
import asyncio import asyncio
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -651,7 +742,7 @@ class AsyncMethodsTests(TestCase):
"""_leave_group() should ignore groups not in _groups.""" """_leave_group() should ignore groups not in _groups."""
import asyncio import asyncio
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -666,16 +757,15 @@ class AsyncMethodsTests(TestCase):
await channel._leave_group("unknown-group") await channel._leave_group("unknown-group")
return channel._groups 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() channel._channel_layer.group_discard.assert_not_called()
def test_leave_all_groups(self): def test_leave_all_groups(self):
"""_leave_all_groups() should leave all joined groups.""" """_leave_all_groups() should leave all joined groups."""
import asyncio import asyncio
class TestChannel(ReactChannel): class TestChannel(Channel):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
@@ -700,8 +790,8 @@ class AsyncMethodsTests(TestCase):
"""_broadcast() should send message to channel layer.""" """_broadcast() should send message to channel layer."""
import asyncio import asyncio
class TestChannel(ReactChannel): class TestChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -713,7 +803,7 @@ class AsyncMethodsTests(TestCase):
channel = TestChannel() channel = TestChannel()
channel._channel_layer = AsyncMock() channel._channel_layer = AsyncMock()
message = TestChannel.DjangoMessage(text="Hello") message = TestChannel.ServerMessage(text="Hello")
async def test(): async def test():
await channel._broadcast("my-group", message) await channel._broadcast("my-group", message)
@@ -747,8 +837,8 @@ class ServerPushTests(TestCase):
"""push() should work for channels without params.""" """push() should work for channels without params."""
import asyncio import asyncio
class NotificationChannel(ReactChannel): class NotificationChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
title: str title: str
body: str body: str
@@ -762,7 +852,7 @@ class ServerPushTests(TestCase):
mock_layer = AsyncMock() mock_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
message = NotificationChannel.DjangoMessage( message = NotificationChannel.ServerMessage(
title="Alert", body="Something happened" title="Alert", body="Something happened"
) )
@@ -781,11 +871,11 @@ class ServerPushTests(TestCase):
"""push() should work for channels with params.""" """push() should work for channels with params."""
import asyncio import asyncio
class RoomChannel(ReactChannel): class RoomChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params): def authorize(self, params):
@@ -798,7 +888,7 @@ class ServerPushTests(TestCase):
mock_layer = AsyncMock() mock_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
message = RoomChannel.DjangoMessage(text="Hello room!") message = RoomChannel.ServerMessage(text="Hello room!")
async def test(): async def test():
await RoomChannel.push(room="general", message=message) await RoomChannel.push(room="general", message=message)
@@ -814,10 +904,9 @@ class ServerPushTests(TestCase):
def test_push_without_channel_layer_warns(self): def test_push_without_channel_layer_warns(self):
"""push() should warn when no channel layer is configured.""" """push() should warn when no channel layer is configured."""
import asyncio import asyncio
import logging
class TestChannel(ReactChannel): class TestChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -829,7 +918,7 @@ class ServerPushTests(TestCase):
with patch("channels.layers.get_channel_layer") as mock_get_layer: with patch("channels.layers.get_channel_layer") as mock_get_layer:
mock_get_layer.return_value = None 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: with self.assertLogs("mizan.channels", level="WARNING") as cm:
@@ -868,7 +957,6 @@ class ManagementCommandTests(TestCase):
output = out.getvalue() output = out.getvalue()
# Should be valid JSON with OpenAPI structure
schema = json.loads(output) schema = json.loads(output)
self.assertIn("openapi", schema) self.assertIn("openapi", schema)
@@ -879,7 +967,7 @@ class ManagementCommandTests(TestCase):
from io import StringIO from io import StringIO
from django.core.management import call_command from django.core.management import call_command
class TestChannel(ReactChannel): class TestChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
id: int id: int
@@ -897,24 +985,101 @@ class ManagementCommandTests(TestCase):
output = out.getvalue() output = out.getvalue()
schema = json.loads(output) schema = json.loads(output)
# Check that channel is in x-mizan-channels metadata
channel_names = [c["name"] for c in schema["x-mizan-channels"]] channel_names = [c["name"] for c in schema["x-mizan-channels"]]
self.assertIn("export-test", channel_names) 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): def test_export_command_respects_indent(self):
"""export_channels_schema should respect --indent option.""" """export_channels_schema should respect --indent option."""
from io import StringIO from io import StringIO
from django.core.management import call_command from django.core.management import call_command
# With indent
out_indent = StringIO() out_indent = StringIO()
call_command("export_channels_schema", indent=2, stdout=out_indent) call_command("export_channels_schema", indent=2, stdout=out_indent)
# Without indent (compact)
out_compact = StringIO() out_compact = StringIO()
call_command("export_channels_schema", indent=0, stdout=out_compact) 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())) self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
@@ -927,12 +1092,10 @@ class WebSocketRPCTests(TestCase):
"""Tests for WebSocket RPC functionality.""" """Tests for WebSocket RPC functionality."""
def setUp(self): def setUp(self):
# Clear mizan registry
from mizan_core.registry import clear_registry from mizan_core.registry import clear_registry
clear_registry() clear_registry()
# Register test functions
from mizan.client import client from mizan.client import client
from mizan_core.registry import register from mizan_core.registry import register
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -27,7 +27,7 @@ from mizan_core.registry import (
) )
from mizan.forms import register_form from mizan.forms import register_form
from mizan.client import ServerFunction, client, ReactContext, GlobalContext 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(): def setup_function_style_tests():
"""Register function-style test functions. """
Register the function-style test functions. Applying @client does not put
Note: Since @client no longer auto-registers (registration happens via a function in the registry, so each one is passed to register() here.
mizan_clients() discovery), we explicitly register each function here.
""" """
@client @client
@@ -514,8 +513,8 @@ class ContextTests(TestCase):
fn = get_function("global_context") fn = get_function("global_context")
self.assertEqual(fn._meta.get("context"), "global") self.assertEqual(fn._meta.get("context"), "global")
def test_context_local(self): def test_context_arbitrary_name_is_verbatim_and_silent(self):
"""Test @client(context='local') still works with deprecation warning.""" """Any non-empty context string becomes the name verbatim, with no warning."""
import warnings import warnings
class CtxOutput(BaseModel): class CtxOutput(BaseModel):
@@ -528,8 +527,7 @@ class ContextTests(TestCase):
def local_context(request: HttpRequest, user_id: int) -> CtxOutput: def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
return CtxOutput(data=f"user_{user_id}") return CtxOutput(data=f"user_{user_id}")
self.assertEqual(len(w), 1) self.assertEqual([str(entry.message) for entry in w], [])
self.assertIn("deprecated", str(w[0].message).lower())
register(local_context, "local_context") register(local_context, "local_context")
@@ -1019,7 +1017,7 @@ class ServerDrivenInvalidationTests(TestCase):
self.assertIn("team_info", data) self.assertIn("team_info", data)
self.assertEqual(data["team_info"]["name"], "team_3") 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") self.assertEqual(response["Cache-Control"], "no-store")
def test_context_error_not_cached(self): def test_context_error_not_cached(self):
@@ -1175,7 +1173,7 @@ class ContextFetchTests(TestCase):
class ChannelTests(TestCase): class ChannelTests(TestCase):
"""Tests for ReactChannel.""" """Tests for Channel."""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -1187,8 +1185,8 @@ class ChannelTests(TestCase):
"""Test channel registration.""" """Test channel registration."""
from mizan.channels import register as register_channel, get_channel from mizan.channels import register as register_channel, get_channel
class TestChannel(ReactChannel): class TestChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -1201,14 +1199,14 @@ class ChannelTests(TestCase):
"""Test channel schema export.""" """Test channel schema export."""
from mizan.channels import register as register_channel from mizan.channels import register as register_channel
class ChatChannel(ReactChannel): class ChatChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: int room: int
class ReactMessage(BaseModel): class ClientMessage(BaseModel):
text: str text: str
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
user: str user: str
text: str text: str
@@ -1225,16 +1223,16 @@ class ChannelTests(TestCase):
chat_schema = schema["channels"]["chat"] chat_schema = schema["channels"]["chat"]
self.assertEqual(chat_schema["type"], "channel") self.assertEqual(chat_schema["type"], "channel")
self.assertIn("params", chat_schema) self.assertIn("params", chat_schema)
self.assertIn("react_message", chat_schema) self.assertIn("client_message", chat_schema)
self.assertIn("django_message", chat_schema) self.assertIn("server_message", chat_schema)
self.assertTrue(chat_schema["bidirectional"]) self.assertTrue(chat_schema["bidirectional"])
def test_server_push_only_channel(self): 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 from mizan.channels import register as register_channel
class NotificationsChannel(ReactChannel): class NotificationsChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
title: str title: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -1244,7 +1242,7 @@ class ChannelTests(TestCase):
schema = get_schema() schema = get_schema()
notif_schema = schema["channels"]["notifications"] notif_schema = schema["channels"]["notifications"]
self.assertNotIn("react_message", notif_schema) self.assertNotIn("client_message", notif_schema)
self.assertFalse(notif_schema["bidirectional"]) self.assertFalse(notif_schema["bidirectional"])
@@ -1374,10 +1372,9 @@ class TypeAnnotationTests(TestCase):
""" """
Test that Optional[BaseModel] return types are NOT wrapped in 'result'. Test that Optional[BaseModel] return types are NOT wrapped in 'result'.
This is a regression test for the bug where `UserOutput | None` was Union types are not recognized by `isinstance(t, type)`, so
incorrectly treated as a primitive type (because Union types aren't `UserOutput | None` can be mistaken for a primitive and wrapped in a
recognized by `isinstance(t, type)`), causing the output to be wrapped 'result' field. This pins that it is not.
in a 'result' field.
""" """
import types import types
@@ -1663,6 +1660,28 @@ class mizanFormMixinTests(TestCase):
self.assertFalse(result.data["success"]) self.assertFalse(result.data["success"])
self.assertIn("errors", result.data) 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): def test_form_meta_serialization(self):
"""Test that mizanFormMeta serializes correctly (auth excluded).""" """Test that mizanFormMeta serializes correctly (auth excluded)."""
from mizan.forms import mizanFormMeta from mizan.forms import mizanFormMeta
@@ -1718,6 +1737,32 @@ class mizanFormMixinTests(TestCase):
self.assertEqual(len(result.data["fields"]), 1) self.assertEqual(len(result.data["fields"]), 1)
self.assertEqual(result.data["fields"][0]["type"], "text") 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): def test_formset_functions_not_registered_by_default(self):
"""Test that formset functions are not registered by default.""" """Test that formset functions are not registered by default."""
from django import forms from django import forms
@@ -1847,7 +1892,7 @@ class HTTPIntegrationTests(TestCase):
self.assertEqual(data["user_profile"]["name"], "user_5") self.assertEqual(data["user_profile"]["name"], "user_5")
self.assertEqual(data["user_orders"]["count"], 50) 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") self.assertEqual(response["Cache-Control"], "no-store")
def test_context_fetch_string_to_int_coercion(self): 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): class EdgeCompatibilityTests(TestCase):
""" """
Tests that prove Edge caching is possible. Every failure mode that Response properties a CDN layer reads: byte-identical bodies for identical
would break a CDN layer is tested here without building the CDN. 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): def setUp(self):
@@ -2182,7 +2228,7 @@ class EdgeCompatibilityTests(TestCase):
# ── Deterministic JSON ────────────────────────────────────────────────── # ── Deterministic JSON ──────────────────────────────────────────────────
def test_deterministic_json_output(self): 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") r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
r2 = 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 ─────────────────────────────────────────── # ── Cache-Control correctness ───────────────────────────────────────────
def test_context_get_no_store(self): 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") response = self.client.get("/api/mizan/ctx/user/?user_id=5")
self.assertEqual(response["Cache-Control"], "no-store") self.assertEqual(response["Cache-Control"], "no-store")
def test_mutation_post_not_cacheable(self): 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( response = self.client.post(
"/api/mizan/call/", "/api/mizan/call/",
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "X"}}), 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") self.assertEqual(response["Cache-Control"], "no-store")
def test_error_response_not_cacheable(self): 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/") response = self.client.get("/api/mizan/ctx/nonexistent/")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response["Cache-Control"], "no-store") self.assertEqual(response["Cache-Control"], "no-store")
def test_different_params_different_response(self): 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") r1 = self.client.get("/api/mizan/ctx/user/?user_id=5")
r2 = self.client.get("/api/mizan/ctx/user/?user_id=6") r2 = self.client.get("/api/mizan/ctx/user/?user_id=6")
@@ -2251,7 +2297,7 @@ class EdgeCompatibilityTests(TestCase):
header = response["X-Mizan-Invalidate"] header = response["X-Mizan-Invalidate"]
# Parse the header (this is what Edge would do) # Parse the header back into structured entries
entries = [] entries = []
for part in header.split(", "): for part in header.split(", "):
segments = part.split(";") segments = part.split(";")
@@ -2306,7 +2352,7 @@ class EdgeCompatibilityTests(TestCase):
# ── Query param ordering doesn't affect content ───────────────────────── # ── Query param ordering doesn't affect content ─────────────────────────
def test_param_order_irrelevant(self): 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")) @client(context=ReactContext("multi"))
def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput: def multi_param(request: HttpRequest, a: int, b: int) -> ValidOutput:
return ValidOutput(valid=True) return ValidOutput(valid=True)
@@ -2358,7 +2404,7 @@ class EdgeCompatibilityTests(TestCase):
] ]
header = _format_invalidate_header(original) header = _format_invalidate_header(original)
# Parse (what Edge would do) # Parse back
segments = header.split(";") segments = header.split(";")
ctx = segments[0] ctx = segments[0]
params = {} params = {}
@@ -3339,7 +3385,8 @@ def _redis_available() -> bool:
client = redis.from_url(REDIS_URL, socket_connect_timeout=1) client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
client.ping() client.ping()
return True return True
except Exception: except Exception as e:
print(f"Redis probe failed for {REDIS_URL}: {type(e).__name__}: {e}")
return False return False

View 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(), [])

View File

@@ -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 Grouped by the surface each one drives: memory exhaustion, type confusion at
the protocol. Focus areas: the serialization boundary, concurrent execution, Pydantic validation bypass,
WebSocket protocol framing, timing measurement, Unicode normalization, JSON
1. Race conditions and TOCTOU vulnerabilities parsing limits, authorization boundaries, and registration collisions.
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.
""" """
import asyncio import asyncio
@@ -485,9 +473,6 @@ class RaceConditionTests(TestCase):
result = execute_function(request, "timed_auth_func", None) 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) self.assertIsInstance(result, FunctionResult)
# On first access is_authenticated returns True, on second it returns False # 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) # 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. Attempt to bypass Pydantic validation.
Note: The @client decorator creates dynamic Pydantic models from function The @client decorator builds the Input model from the function's parameter
parameters. Custom validators must be on the parameter types themselves, annotations, so a custom validator has to live on the parameter type itself.
not separate classes. This tests the actual validation behavior.
Attack vectors: Attack vectors:
- Type coercion bypass - Type coercion bypass
@@ -710,17 +694,17 @@ class WebSocketProtocolTests(TestCase):
Try rapid subscribe/unsubscribe cycles and malformed params. 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 mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
class TestChannel(ReactChannel): class TestChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params): def authorize(self, params):
@@ -751,14 +735,14 @@ class WebSocketProtocolTests(TestCase):
""" """
Test attempting to subscribe to the same channel twice. 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 mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
class TestChannel(ReactChannel): class TestChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -859,7 +843,6 @@ class TimingSideChannelTests(TestCase):
# Large differences could leak function existence # Large differences could leak function existence
ratio = max(avg_existing, avg_nonexistent) / min(avg_existing, avg_nonexistent) 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"\nTiming ratio (existing/nonexistent): {ratio:.2f}")
print(f"Avg existing: {avg_existing*1000:.3f}ms") print(f"Avg existing: {avg_existing*1000:.3f}ms")
print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms") print(f"Avg nonexistent: {avg_nonexistent*1000:.3f}ms")
@@ -944,14 +927,15 @@ class UnicodeNormalizationTests(TestCase):
""" """
request = self._make_request() 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 = [ lookalikes = [
"\u0430dmin", # Cyrillic 'а' (U+0430) instead of Latin 'a' chr(0x0430) + "dmin", # Cyrillic small a
"adm\u0131n", # Turkish dotless i (U+0131) "adm" + chr(0x0131) + "n", # Turkish dotless i
"\u00e1dmin", # Latin a with acute chr(0x00E1) + "dmin", # Latin a with acute
"\uff41\uff44\uff4d\uff49\uff4e", # Fullwidth characters "".join(chr(c) for c in (0xFF41, 0xFF44, 0xFF4D, 0xFF49, 0xFF4E)),
"\u0251dmin", # Latin alpha chr(0x0251) + "dmin", # Latin alpha
"\u0430\u0501m\u0456n", # Mix of Cyrillic characters chr(0x0430) + chr(0x0501) + "m" + chr(0x0456) + "n", # Cyrillic mix
] ]
for lookalike in lookalikes: for lookalike in lookalikes:
@@ -970,11 +954,10 @@ class UnicodeNormalizationTests(TestCase):
request = self._make_request() request = self._make_request()
# é can be represented as: # Built from chr() so the two spellings stay distinguishable in source:
# 1. U+00E9 (precomposed) # U+00E9 precomposed vs. "e" + U+0301 combining acute.
# 2. U+0065 U+0301 (decomposed: e + combining acute) precomposed = "caf" + chr(0x00E9)
precomposed = "caf\u00e9" # café with precomposed é decomposed = "cafe" + chr(0x0301)
decomposed = "cafe\u0301" # café with combining acute
# These look identical but are different byte sequences # These look identical but are different byte sequences
self.assertNotEqual(precomposed, decomposed) self.assertNotEqual(precomposed, decomposed)
@@ -994,12 +977,13 @@ class UnicodeNormalizationTests(TestCase):
""" """
request = self._make_request() request = self._make_request()
# Built from chr(): every one of these renders as nothing at all.
zero_width_chars = [ zero_width_chars = [
"\u200b", # Zero-width space chr(0x200B), # Zero-width space
"\u200c", # Zero-width non-joiner chr(0x200C), # Zero-width non-joiner
"\u200d", # Zero-width joiner chr(0x200D), # Zero-width joiner
"\u2060", # Word joiner chr(0x2060), # Word joiner
"\ufeff", # Zero-width no-break space (BOM) chr(0xFEFF), # Zero-width no-break space (BOM)
] ]
for zwc in zero_width_chars: for zwc in zero_width_chars:
@@ -1066,11 +1050,9 @@ class JSONParsingEdgeCaseTests(TestCase):
try: try:
result = execute_function(request, "json_func", {"data": nested}) result = execute_function(request, "json_func", {"data": nested})
# Should either succeed or fail gracefully
self.assertIn(type(result), [FunctionResult, FunctionError]) self.assertIn(type(result), [FunctionResult, FunctionError])
except RecursionError: except RecursionError as exc:
# This is acceptable - Python's recursion limit hit print(f"\nCPython recursion limit reached at 500 levels: {exc}")
pass
def test_json_number_precision(self): def test_json_number_precision(self):
""" """
@@ -1175,8 +1157,8 @@ class RegistrationSecurityTests(TestCase):
""" """
Test that a different function cannot override an existing one. Test that a different function cannot override an existing one.
Note: Re-registration of the same function name IS allowed for hot reload. Re-registering the same object under its own name is allowed; a
But a DIFFERENT function cannot take over an existing name. different object claiming a taken name raises.
""" """
from mizan.client import ServerFunction from mizan.client import ServerFunction
from mizan_core.registry import register from mizan_core.registry import register

View File

@@ -1,26 +1,16 @@
""" """
Security-focused E2E tests for mizan server functions. Adversarial-input tests: hostile payloads driven through execute_function,
function_call_view, and the WebSocket consumer.
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
""" """
import json 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 import get_user_model
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest from django.http import HttpRequest
from django.test import RequestFactory, TestCase, Client, override_settings from django.test import RequestFactory, TestCase, Client, override_settings
from pydantic import BaseModel, field_validator from pydantic import BaseModel
from mizan.client.executor import ( from mizan.client.executor import (
ErrorCode, ErrorCode,
@@ -29,9 +19,9 @@ from mizan.client.executor import (
execute_function, execute_function,
function_call_view, function_call_view,
) )
from mizan_core.registry import clear_registry, register, register_as, get_function from mizan_core.registry import clear_registry, register
from mizan.client import ServerFunction, client from mizan.client import client
from mizan.channels import ReactChannel from mizan.channels import Channel
User = get_user_model() User = get_user_model()
@@ -46,10 +36,6 @@ class SimpleOutput(BaseModel):
value: str value: str
class NestedInput(BaseModel):
level1: dict
class DeeplyNestedOutput(BaseModel): class DeeplyNestedOutput(BaseModel):
depth: int depth: int
@@ -69,12 +55,7 @@ class AdminOnlyOutput(BaseModel):
class InputValidationSecurityTests(TestCase): class InputValidationSecurityTests(TestCase):
""" """Oversized strings, deep nesting, unicode codepoints, type mismatches, and extra fields."""
Test input validation for security edge cases.
Verifies that Pydantic validation catches malicious or malformed input
BEFORE any function code executes.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -148,7 +129,6 @@ class InputValidationSecurityTests(TestCase):
"""Test that null bytes in strings are handled safely.""" """Test that null bytes in strings are handled safely."""
request = self._make_request() request = self._make_request()
# Null byte injection attempt
payload = "normal\x00injected" payload = "normal\x00injected"
result = execute_function(request, "echo_any", {"message": payload}) result = execute_function(request, "echo_any", {"message": payload})
@@ -160,19 +140,21 @@ class InputValidationSecurityTests(TestCase):
"""Test various unicode edge cases.""" """Test various unicode edge cases."""
request = self._make_request() 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 = [ test_cases = [
# Zero-width characters # Zero-width characters
"normal\u200btext", "normal​text",
# Right-to-left override (potential display issues) # Right-to-left override (potential display issues)
"test\u202eevil", "test‮evil",
# Emoji sequences # Emoji sequences
"👨‍👩‍👧‍👦", "\U0001f468‍\U0001f469‍\U0001f467‍\U0001f466",
# Combining characters # Combining characters
"a\u0300\u0301\u0302", "à́̂",
# Null character # Null character
"test\u0000null", "test\x00null",
# Replacement character # Replacement character
"test\ufffdreplace", "test<EFBFBD>replace",
] ]
for payload in test_cases: for payload in test_cases:
@@ -226,12 +208,7 @@ class InputValidationSecurityTests(TestCase):
class AuthorizationSecurityTests(TestCase): class AuthorizationSecurityTests(TestCase):
""" """execute_function outcomes for anonymous, authenticated, staff, duck-typed, and cross-user callers."""
Test authorization bypass attempts.
Verifies that authentication/authorization checks can't be bypassed
through various attack vectors.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -264,8 +241,6 @@ class AuthorizationSecurityTests(TestCase):
@client @client
def leaky_auth_check(request: HttpRequest) -> SimpleOutput: 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: if not request.user.is_authenticated:
raise PermissionError("User not logged in") raise PermissionError("User not logged in")
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
@@ -321,9 +296,8 @@ class AuthorizationSecurityTests(TestCase):
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
def test_spoofed_is_authenticated_attribute(self): 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: class FakeUser:
is_authenticated = True is_authenticated = True
id = 999 id = 999
@@ -331,8 +305,7 @@ class AuthorizationSecurityTests(TestCase):
request = self._make_request(user=FakeUser()) request = self._make_request(user=FakeUser())
result = execute_function(request, "requires_auth", None) result = execute_function(request, "requires_auth", None)
# This actually works because we only check is_authenticated # execute_function only reads is_authenticated, so this duck-type passes
# This test documents the behavior - real Django handles this
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
def test_user_id_manipulation_blocked(self): def test_user_id_manipulation_blocked(self):
@@ -340,7 +313,6 @@ class AuthorizationSecurityTests(TestCase):
@client @client
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput: def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
# Properly checking: can only access own data
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
if request.user.id != target_user_id: if request.user.id != target_user_id:
@@ -368,11 +340,7 @@ class AuthorizationSecurityTests(TestCase):
class HTTPEndpointSecurityTests(TestCase): class HTTPEndpointSecurityTests(TestCase):
""" """Method restrictions, JSON body parsing, and function-name lookup on the HTTP view."""
Test HTTP endpoint security.
Verifies CSRF protection, method restrictions, and JSON parsing security.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -430,7 +398,6 @@ class HTTPEndpointSecurityTests(TestCase):
"/api/mizan/call/", data="{invalid json", content_type="application/json" "/api/mizan/call/", data="{invalid json", content_type="application/json"
) )
request.user = AnonymousUser() request.user = AnonymousUser()
# Bypass CSRF for this test
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
response = function_call_view(request) response = function_call_view(request)
@@ -487,7 +454,6 @@ class HTTPEndpointSecurityTests(TestCase):
def test_function_identifier_traversal(self): def test_function_identifier_traversal(self):
"""Test that path traversal-style function identifiers are handled.""" """Test that path traversal-style function identifiers are handled."""
# Try various path traversal attempts as function identifiers
malicious_names = [ malicious_names = [
"../../../etc/passwd", "../../../etc/passwd",
"..\\..\\windows\\system32", "..\\..\\windows\\system32",
@@ -515,11 +481,7 @@ class HTTPEndpointSecurityTests(TestCase):
class WebSocketRPCSecurityTests(TestCase): class WebSocketRPCSecurityTests(TestCase):
""" """Malformed and unresolvable RPC frames over the WebSocket consumer."""
Test WebSocket RPC security.
Verifies that malformed messages and unauthorized calls are handled safely.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -555,11 +517,9 @@ class WebSocketRPCSecurityTests(TestCase):
consumer.channel_layer = MagicMock() consumer.channel_layer = MagicMock()
consumer.channel_name = "test" consumer.channel_name = "test"
# Track sent messages
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call without id
async_to_sync(consumer._handle_rpc)( async_to_sync(consumer._handle_rpc)(
{"fn": "ws_echo", "args": {"message": "test"}} {"fn": "ws_echo", "args": {"message": "test"}}
) )
@@ -581,10 +541,8 @@ class WebSocketRPCSecurityTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call without fn
async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}}) async_to_sync(consumer._handle_rpc)({"id": "123", "args": {}})
# Should return error
self.assertEqual(len(sent_messages), 1) self.assertEqual(len(sent_messages), 1)
self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["ok"], False)
self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST") self.assertEqual(sent_messages[0]["error"]["code"], "BAD_REQUEST")
@@ -622,20 +580,19 @@ class WebSocketRPCSecurityTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) 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)( async_to_sync(consumer._handle_rpc)(
{ {
"id": "123", "id": "123",
"fn": "ws_echo", "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() sent_messages.clear()
async_to_sync(consumer._handle_rpc)( 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) self.assertEqual(sent_messages[0]["ok"], False)
@@ -648,11 +605,7 @@ class WebSocketRPCSecurityTests(TestCase):
class InformationDisclosureTests(TestCase): class InformationDisclosureTests(TestCase):
""" """Contents of FunctionError responses with DEBUG=False."""
Test information disclosure vulnerabilities.
Verifies that error messages don't leak sensitive information.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -667,7 +620,6 @@ class InformationDisclosureTests(TestCase):
@client @client
def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput: def error_with_sensitive_data(request: HttpRequest) -> SimpleOutput:
# Simulate accessing sensitive config that might leak in error
secret_key = "super_secret_key_12345" secret_key = "super_secret_key_12345"
raise RuntimeError(f"Database error with key: {secret_key}") 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.""" """Test that error messages don't help enumerate functions in production."""
request = self._make_request() request = self._make_request()
# Try various function names/UUIDs
test_names = [ test_names = [
"admin_panel", "admin_panel",
"get_all_users", "get_all_users",
@@ -716,8 +667,7 @@ class InformationDisclosureTests(TestCase):
result = execute_function(request, name, None) result = execute_function(request, name, None)
self.assertIsInstance(result, FunctionError) self.assertIsInstance(result, FunctionError)
self.assertEqual(result.code, ErrorCode.NOT_FOUND) self.assertEqual(result.code, ErrorCode.NOT_FOUND)
# In production (DEBUG=False), error message is generic # With DEBUG=False the message is identical for every name
# - doesn't reveal function name or UUID existence
self.assertEqual(result.message, "Function not found") self.assertEqual(result.message, "Function not found")
def test_validation_errors_dont_leak_internals(self): def test_validation_errors_dont_leak_internals(self):
@@ -732,7 +682,7 @@ class InformationDisclosureTests(TestCase):
request = self._make_request() request = self._make_request()
result = execute_function(request, "validated_func", {"secret_field": 123}) 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"}) result = execute_function(request, "validated_func", {"wrong_field": "test"})
self.assertIsInstance(result, FunctionError) self.assertIsInstance(result, FunctionError)
@@ -747,13 +697,7 @@ class InformationDisclosureTests(TestCase):
class InjectionPreventionTests(TestCase): class InjectionPreventionTests(TestCase):
""" """SQL-, shell-, template-, and JSON-shaped payloads through echo and key-count functions."""
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.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -768,7 +712,6 @@ class InjectionPreventionTests(TestCase):
@client @client
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput: def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
# This function just echoes - the test is about validation
return SimpleOutput(value=user_input) return SimpleOutput(value=user_input)
register(echo_safe, "echo_safe") register(echo_safe, "echo_safe")
@@ -797,9 +740,7 @@ class InjectionPreventionTests(TestCase):
for payload in sql_payloads: for payload in sql_payloads:
result = execute_function(request, "echo_safe", {"user_input": payload}) result = execute_function(request, "echo_safe", {"user_input": payload})
# Should succeed - it's just a string, not executed as SQL
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
# The payload is returned as-is (no SQL execution)
self.assertEqual(result.data["value"], payload) self.assertEqual(result.data["value"], payload)
def test_command_injection_in_string_field(self): def test_command_injection_in_string_field(self):
@@ -816,7 +757,6 @@ class InjectionPreventionTests(TestCase):
for payload in cmd_payloads: for payload in cmd_payloads:
result = execute_function(request, "echo_safe", {"user_input": payload}) result = execute_function(request, "echo_safe", {"user_input": payload})
# Should succeed - it's just a string
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["value"], payload) self.assertEqual(result.data["value"], payload)
@@ -842,12 +782,11 @@ class InjectionPreventionTests(TestCase):
"""Test that special JSON values are handled safely.""" """Test that special JSON values are handled safely."""
request = self._make_request() request = self._make_request()
# Various JSON edge cases
test_cases = [ test_cases = [
{"__proto__": {"polluted": True}}, {"__proto__": {"polluted": True}},
{"constructor": {"prototype": {}}}, {"constructor": {"prototype": {}}},
{"key": None}, {"key": None},
{"key": float("inf")}, # This will fail JSON serialization {"key": float("inf")},
] ]
for data in test_cases: for data in test_cases:
@@ -874,15 +813,10 @@ class InjectionPreventionTests(TestCase):
class ChannelAuthorizationTests(TestCase): class ChannelAuthorizationTests(TestCase):
""" """Subscription outcomes when authorize() returns False, raises, or gets bad params."""
Test WebSocket channel authorization.
Verifies that channel subscriptions properly check permissions.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
# Also clear the channels registry
from mizan.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
channels_registry.clear() channels_registry.clear()
@@ -896,10 +830,10 @@ class ChannelAuthorizationTests(TestCase):
def _register_test_channels(self): def _register_test_channels(self):
"""Register test channels using the channels module's register.""" """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 PublicChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -908,8 +842,8 @@ class ChannelAuthorizationTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "public" return "public"
class AuthChannel(ReactChannel): class AuthChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -918,15 +852,14 @@ class ChannelAuthorizationTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "auth" return "auth"
class RoomChannel(ReactChannel): class RoomChannel(Channel):
class Params(BaseModel): class Params(BaseModel):
room_id: int room_id: int
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params): def authorize(self, params):
# Only allow access to room 1 and 2
return params.room_id in [1, 2] return params.room_id in [1, 2]
def group(self, params): def group(self, params):
@@ -938,12 +871,12 @@ class ChannelAuthorizationTests(TestCase):
def test_authorize_exception_handling(self): def test_authorize_exception_handling(self):
"""Test that exceptions in authorize() are handled safely.""" """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 mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
class ErrorChannel(ReactChannel): class ErrorChannel(Channel):
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
text: str text: str
def authorize(self, params=None): def authorize(self, params=None):
@@ -987,7 +920,6 @@ class ChannelAuthorizationTests(TestCase):
{"channel": "auth-channel", "params": {}} {"channel": "auth-channel", "params": {}}
) )
# Should be rejected
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
self.assertIn("Not authorized", sent_messages[0]["error"]) self.assertIn("Not authorized", sent_messages[0]["error"])
@@ -1009,7 +941,6 @@ class ChannelAuthorizationTests(TestCase):
{"channel": "room-channel", "params": {"room_id": "not_an_int"}} {"channel": "room-channel", "params": {"room_id": "not_an_int"}}
) )
# Should fail validation
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
def test_room_authorization_enforced(self): def test_room_authorization_enforced(self):
@@ -1045,13 +976,7 @@ class ChannelAuthorizationTests(TestCase):
class AbusePreventionTests(TestCase): class AbusePreventionTests(TestCase):
""" """Repeated and batched execute_function calls."""
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.
"""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -1079,13 +1004,11 @@ class AbusePreventionTests(TestCase):
"""Test that rapid function calls don't cause issues.""" """Test that rapid function calls don't cause issues."""
request = self._make_request() request = self._make_request()
# Make 100 rapid calls
results = [] results = []
for _ in range(100): for _ in range(100):
result = execute_function(request, "simple_func", None) result = execute_function(request, "simple_func", None)
results.append(result) results.append(result)
# All should succeed (no rate limiting currently) and return expected data
for result in results: for result in results:
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["value"], "ok") self.assertEqual(result.data["value"], "ok")

View File

@@ -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. The bridge shells out to Bun, so every test here skips unless `bun` is on PATH
Tests skip gracefully if Bun is not available. and the worker script is present.
""" """
import os import os
@@ -11,13 +11,12 @@ import threading
from django.test import SimpleTestCase, RequestFactory from django.test import SimpleTestCase, RequestFactory
# Path to the test worker _REPO_ROOT = os.path.normpath(
_SSR_WORKER = os.path.join( os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
os.path.dirname(__file__),
"..", "..", "..", "..", "..", # up to repo root
"packages", "mizan-ssr", "src", "test-worker.tsx",
) )
_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 _BUN_AVAILABLE = shutil.which("bun") is not None
_SKIP_MSG = "Bun not available" _SKIP_MSG = "Bun not available"
@@ -30,66 +29,52 @@ class SSRBridgeTests(SimpleTestCase):
if not _BUN_AVAILABLE: if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG) self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER): 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 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): def tearDown(self):
if hasattr(self, "bridge"): if hasattr(self, "bridge"):
self.bridge.shutdown() self.bridge.shutdown()
def test_ping(self): def test_render_starts_worker_and_returns_html(self):
"""Worker starts and responds to ping.""" """The first render boots the worker and returns rendered markup."""
self.assertTrue(self.bridge.ping()) result = self.bridge.render(_HELLO, {"name": "World"})
def test_render_simple(self):
"""Renders a simple component to HTML."""
result = self.bridge.render("Hello", {"name": "World"})
self.assertIn("Hello,", result.html) self.assertIn("Hello,", result.html)
self.assertIn("World", result.html) self.assertIn("World", result.html)
def test_render_with_props(self): def test_render_passes_props_through(self):
"""Renders a component with multiple props.""" """Props reach the component."""
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"}) result = self.bridge.render(_HELLO, {"name": "Alice"})
self.assertIn("Alice", result.html) self.assertIn("Alice", result.html)
self.assertIn("42", result.html) self.assertIn('data-mizan-component="Hello"', result.html)
def test_render_missing_component(self): def test_render_missing_file_raises(self):
"""Rendering an unregistered component raises RuntimeError.""" """Rendering a path with no module raises RuntimeError naming the failure."""
with self.assertRaises(RuntimeError) as ctx: missing = os.path.join(_COMPONENT_DIR, "DoesNotExist.tsx")
self.bridge.render("NonExistent", {}) with self.assertRaises(RuntimeError):
self.assertIn("not registered", str(ctx.exception)) self.bridge.render(missing, {})
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_crash_recovery(self): def test_crash_recovery(self):
"""Bridge restarts the worker if it dies.""" """The bridge restarts the worker if it dies."""
# First render works result = self.bridge.render(_HELLO, {"name": "Before"})
result = self.bridge.render("Hello", {"name": "Before"})
self.assertIn("Before", result.html) self.assertIn("Before", result.html)
# Kill the subprocess
self.bridge._proc.kill() self.bridge._proc.kill()
self.bridge._proc.wait() 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) self.assertIn("After", result.html)
def test_concurrent_renders(self): def test_concurrent_renders(self):
"""Multiple threads can render simultaneously.""" """Concurrent callers each get their own response matched by message id."""
results = {} results = {}
errors = {} errors = {}
def render_in_thread(name: str, idx: int): def render_in_thread(name: str, idx: int):
try: try:
result = self.bridge.render("Hello", {"name": name}) results[idx] = self.bridge.render(_HELLO, {"name": name}).html
results[idx] = result.html
except Exception as e: except Exception as e:
errors[idx] = e errors[idx] = e
@@ -100,9 +85,9 @@ class SSRBridgeTests(SimpleTestCase):
t.start() t.start()
for t in threads: 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) self.assertEqual(len(results), 5)
for i in range(5): for i in range(5):
self.assertIn(f"User{i}", results[i]) self.assertIn(f"User{i}", results[i])
@@ -115,16 +100,16 @@ class SSRTemplateBackendTests(SimpleTestCase):
if not _BUN_AVAILABLE: if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG) self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER): 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 from mizan.ssr.backend import MizanTemplates
self.engine = MizanTemplates({ self.engine = MizanTemplates({
"NAME": "mizan-test", "NAME": "mizan-test",
"DIRS": [], "DIRS": [_COMPONENT_DIR],
"APP_DIRS": False, "APP_DIRS": False,
"OPTIONS": { "OPTIONS": {
"worker_path": _SSR_WORKER, "worker": _SSR_WORKER,
"timeout": 5, "timeout": 10,
}, },
}) })
self.factory = RequestFactory() self.factory = RequestFactory()
@@ -133,30 +118,40 @@ class SSRTemplateBackendTests(SimpleTestCase):
if hasattr(self, "engine") and self.engine._bridge is not None: if hasattr(self, "engine") and self.engine._bridge is not None:
self.engine._bridge.shutdown() self.engine._bridge.shutdown()
def test_get_template(self): def test_get_template_resolves_name_to_file(self):
"""get_template returns a MizanTemplate.""" """get_template resolves the name against DIRS to an absolute file path."""
from mizan.ssr.backend import MizanTemplate from mizan.ssr.backend import MizanTemplate
template = self.engine.get_template("Hello") template = self.engine.get_template("Hello.tsx")
self.assertIsInstance(template, MizanTemplate) self.assertIsInstance(template, MizanTemplate)
self.assertEqual(template.component_name, "Hello") self.assertEqual(template.file_path, os.path.abspath(_HELLO))
def test_template_render(self): def test_missing_template_raises(self):
"""MizanTemplate.render() produces HTML.""" """A name that resolves to no file under DIRS raises TemplateDoesNotExist."""
template = self.engine.get_template("Hello") 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"}) html = template.render({"name": "Django"})
self.assertIn("Hello,", html) self.assertIn("Hello,", html)
self.assertIn("Django", 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): def test_template_render_strips_django_internals(self):
"""Django-internal context keys (request, csrf_token) are not passed as props.""" """request and csrf_token are dropped from props and from hydration data."""
template = self.engine.get_template("Hello") template = self.engine.get_template("Hello.tsx")
request = self.factory.get("/") 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.assertIn("Test", html)
self.assertNotIn("csrf_token", html)
self.assertNotIn("abc", html)
def test_from_string_raises(self): def test_from_string_is_unsupported(self):
"""from_string is not supported.""" """This engine renders modules by path, so it has no source-string form."""
from django.template import TemplateDoesNotExist with self.assertRaises(NotImplementedError):
with self.assertRaises(TemplateDoesNotExist):
self.engine.from_string("<div>Not supported</div>") self.engine.from_string("<div>Not supported</div>")

View File

@@ -1,14 +1,7 @@
""" """
mizan URL Configuration mizan's HTTP endpoints: session bootstrap, the server-function call endpoint,
and the bundled per-context fetch. Schema export is reachable only through the
HTTP endpoints: `export_mizan_ir` management command, never over HTTP.
- 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
""" """
from django.http import JsonResponse from django.http import JsonResponse
@@ -16,7 +9,7 @@ from django.middleware.csrf import get_token
from django.urls import path from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie 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" app_name = "mizan"
@@ -24,13 +17,8 @@ app_name = "mizan"
@ensure_csrf_cookie @ensure_csrf_cookie
def session_init_view(request): def session_init_view(request):
""" """
Initialize a Django session and return the CSRF token. Start a Django session and return `{"csrfToken": ...}`. The decorator is
what puts the csrftoken cookie on the response.
Used by SSR to establish a session before making authenticated requests.
The @ensure_csrf_cookie decorator ensures the csrftoken cookie is set.
Returns:
{ "csrfToken": "..." }
""" """
return JsonResponse({"csrfToken": get_token(request)}) return JsonResponse({"csrfToken": get_token(request)})

View File

@@ -1,3 +1,5 @@
import uuid
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AbstractBaseUser, AbstractBaseUser,
BaseUserManager, BaseUserManager,
@@ -7,8 +9,6 @@ from django.db import models
class EmailUserManager(BaseUserManager): class EmailUserManager(BaseUserManager):
"""Custom user manager using email as the unique identifier."""
def create_user(self, email, password=None, **extra_fields): def create_user(self, email, password=None, **extra_fields):
if not email: if not email:
raise ValueError("Email is required") raise ValueError("Email is required")
@@ -25,12 +25,6 @@ class EmailUserManager(BaseUserManager):
class EmailUser(AbstractBaseUser, PermissionsMixin): 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) email = models.EmailField(unique=True)
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
@@ -44,11 +38,6 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
app_label = "tests" app_label = "tests"
# ─── Shape test models ──────────────────────────────────────────────────────
import uuid
class TimestampMixin(models.Model): class TimestampMixin(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -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" SECRET_KEY = "test-secret-key-for-standalone-tests-only"
DEBUG = True DEBUG = True
@@ -32,11 +23,9 @@ ROOT_URLCONF = "tests.urls"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 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_PRIVATE_KEY = "test-secret-key-for-testing-only"
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
# Session engine (for test_auth.py SessionStore usage)
SESSION_ENGINE = "django.contrib.sessions.backends.db" SESSION_ENGINE = "django.contrib.sessions.backends.db"
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -1,27 +1,12 @@
""" """
mizan-fastapi — FastAPI backend adapter for the Mizan protocol. mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
HTTP RPC dispatch, context bundling, and a WebSocket carrying channel Re-exports the adapter's surface: two routers (HTTP dispatch and the WebSocket),
subscriptions and RPC on mizan-django's envelope. Forms, Shapes and SSR are the error hierarchy with its exception handlers, and the channel base class with
out of scope — FastAPI projects use native equivalents. its registry.
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")
""" """
from .executor import ( from mizan_fastapi.executor import (
ErrorCode, ErrorCode,
MizanError, MizanError,
NotFound, NotFound,
@@ -34,14 +19,19 @@ from .executor import (
compute_invalidation, compute_invalidation,
execute_function, execute_function,
) )
from .router import router, mizan_exception_handler, mizan_validation_handler from mizan_fastapi.router import router, mizan_exception_handler, mizan_validation_handler
from .websocket import ws_router from mizan_fastapi.websocket import ws_router
from .channels import ReactChannel, broadcast, get_channel, register as register_channel from mizan_fastapi.channels import (
Channel,
broadcast,
get_channel,
register as register_channel,
)
__all__ = [ __all__ = [
"router", "router",
"ws_router", "ws_router",
"ReactChannel", "Channel",
"register_channel", "register_channel",
"get_channel", "get_channel",
"broadcast", "broadcast",

View File

@@ -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 is the fan-out key, so two subscribers with the same params share a group and a push
addressed to those params reaches both. addressed to those params reaches both.
Membership is held in this process. Django's channel layer carries groups across Membership is held in this process. A push therefore reaches only the subscribers
workers through Redis; nothing here does, so a push reaches only the subscribers whose socket is attached to the process that sent it.
connected to the process that sent it. One process — a desktop shell, a single
uvicorn worker — is the shape this serves.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,20 +18,27 @@ from typing import Any, ClassVar
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel from pydantic import BaseModel
from mizan_core.registry import RegistryExtension, register_extension
# group name -> the live sockets subscribed to it # group name -> the live sockets subscribed to it
_groups: dict[str, set[Any]] = defaultdict(set) _groups: dict[str, set[Any]] = defaultdict(set)
_registry: dict[str, type["ReactChannel"]] = {} _registry: dict[str, type["Channel"]] = {}
_lock = asyncio.Lock() _lock = asyncio.Lock()
class ReactChannel: class Channel:
"""A named fan-out. Subclass, override what the channel decides, and register it. """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 The three nested models are the channel's payload types, named from the client's
calls them when they exist. 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] = "" 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: def authorize(self, params: BaseModel | None = None) -> bool:
"""Whether this subscriber may join. Default: anyone may.""" """Whether this subscriber may join. Default: anyone may."""
@@ -68,16 +73,16 @@ class _Params:
return self._values 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 channel_class.name = name
_registry[name] = channel_class _registry[name] = channel_class
def get_channel(name: str) -> type[ReactChannel] | None: def get_channel(name: str) -> type[Channel] | None:
return _registry.get(name) return _registry.get(name)
def registered() -> dict[str, type[ReactChannel]]: def registered() -> dict[str, type[Channel]]:
return dict(_registry) return dict(_registry)
@@ -122,3 +127,36 @@ async def broadcast(
except Exception as e: except Exception as e:
print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}") print(f"mizan.channels: dropping subscriber from {group}: {type(e).__name__}: {e}")
await leave(group, socket) 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())

View File

@@ -6,8 +6,7 @@ Usage:
Imports the named module (whose import side effects must register every Imports the named module (whose import side effects must register every
@client function with `mizan_core.registry`), then writes the canonical @client function with `mizan_core.registry`), then writes the canonical
Mizan IR as KDL to stdout. The Rust codegen binary consumes this Mizan IR as KDL to stdout.
directly.
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -1,15 +1,9 @@
""" """
FastAPI router exposing Mizan's HTTP endpoints: FastAPI router exposing Mizan's HTTP endpoints:
GET /session/ — session-init probe
POST /call/ — RPC dispatch POST /call/ — RPC dispatch
GET /ctx/{context_name}/ — bundled context fetch 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 from __future__ import annotations
@@ -23,7 +17,7 @@ from pydantic import BaseModel, Field
from mizan_core.registry import get_context_groups, get_function from mizan_core.registry import get_context_groups, get_function
from .executor import ( from mizan_fastapi.executor import (
ErrorCode, ErrorCode,
MizanError, MizanError,
NotFound, NotFound,
@@ -45,12 +39,7 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
@router.get("/session/") @router.get("/session/")
async def session_init() -> JSONResponse: async def session_init() -> JSONResponse:
"""Session-init probe. Parity with mizan-django's session endpoint. """Session-init probe. The CSRF slot is null — nothing on this backend issues a token."""
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.
"""
return _no_store({"csrfToken": None}) return _no_store({"csrfToken": None})

View File

@@ -14,13 +14,8 @@ The WebSocket endpoint — channel subscriptions and RPC over one connection.
{"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}} {"id": "request-id", "ok": false, "error": {"code": "...", "message": "..."}}
{"error": "..."} {"error": "..."}
The envelope is mizan-django's, so a client speaks to either backend unchanged. RPC An `rpc` reply's `data` is the `{result, invalidate, merge}` envelope, the same one the
dispatches through the same `execute_function` the HTTP route calls — one dispatch path, HTTP route builds, and both are produced by `execute_function`.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -47,9 +42,9 @@ ws_router = APIRouter()
class _SocketRequest: class _SocketRequest:
"""What a server function receives when the call arrived over the socket. """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 There is no Starlette `Request` on a socket, so this carries the surface a function
carries the surface a function actually reads — `state`, headers, and a method, since actually reads — `state`, headers, and a method, since a socket RPC sends data and
a socket RPC sends data and expects an answer. expects an answer.
""" """
method = "POST" method = "POST"
@@ -64,7 +59,7 @@ class _SocketRequest:
def _params_model(channel_cls: Any, raw: dict[str, Any] | None) -> Any: 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.""" """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: if model is not None and raw:
return model(**raw) return model(**raw)
return channels._Params(raw) if raw else None return channels._Params(raw) if raw else None
@@ -203,10 +198,10 @@ _ACTIONS = {
@ws_router.websocket("/ws/") @ws_router.websocket("/ws/")
async def mizan_socket(socket: WebSocket) -> None: 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 A close is how a socket ends, so the disconnect is logged rather than raised;
rather than raised as a fault; `finally` clears the membership either way. `finally` clears the membership either way.
""" """
await socket.accept() await socket.accept()
try: try:

View File

@@ -88,7 +88,7 @@ def app():
@client @client
async def async_echo(request, text: str) -> EchoOutput: 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) await asyncio.sleep(0)
return EchoOutput(message=f"async: {text}") return EchoOutput(message=f"async: {text}")
@@ -183,18 +183,19 @@ class ContextFetchTests:
assert r.json()["error"]["code"] == "NOT_FOUND" assert r.json()["error"]["code"] == "NOT_FOUND"
# ─── Invalidation ─────────────────────────────────────────────────────────── # ─── Auth gating ────────────────────────────────────────────────────────────
class AuthTests: 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): def test_anonymous_request_to_auth_required_returns_401(self, http):
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}}) r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
assert r.status_code == 401 assert r.status_code == 401
assert r.json()["error"]["code"] == "UNAUTHORIZED" assert r.json()["error"]["code"] == "UNAUTHORIZED"
# ─── Invalidation ───────────────────────────────────────────────────────────
class InvalidationTests: class InvalidationTests:
def test_mutation_emits_invalidate_list(self, http): def test_mutation_emits_invalidate_list(self, http):
r = http.post( r = http.post(
@@ -211,8 +212,6 @@ class InvalidationTests:
class StructuredOutputTests: 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): def test_list_of_basemodel_returns_bare_array(self, http):
r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}}) r = http.post("/api/mizan/call/", json={"fn": "list_items", "args": {}})
assert r.status_code == 200 assert r.status_code == 200
@@ -232,21 +231,20 @@ class StructuredOutputTests:
assert r_missing.json()["result"] is None assert r_missing.json()["result"] is None
# ─── Merge protocol ───────────────────────────────────────────────────────── # ─── Async handlers ─────────────────────────────────────────────────────────
class AsyncHandlerTests: class AsyncHandlerTests:
"""`async def` handlers dispatch on the loop via view.acall."""
def test_async_handler_returns_awaited_result(self, http): def test_async_handler_returns_awaited_result(self, http):
r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}}) r = http.post("/api/mizan/call/", json={"fn": "async_echo", "args": {"text": "hello"}})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["result"] == {"message": "async: hello"} assert r.json()["result"] == {"message": "async: hello"}
class MergeTests: # ─── Merge protocol ─────────────────────────────────────────────────────────
"""@client(merge=...) emits a `merge` field in the response so the kernel can splice without refetch."""
class MergeTests:
def test_merge_target_emits_merge_entry(self, http): def test_merge_target_emits_merge_entry(self, http):
r = http.post( r = http.post(
"/api/mizan/call/", "/api/mizan/call/",
@@ -254,9 +252,8 @@ class MergeTests:
) )
assert r.status_code == 200 assert r.status_code == 200
body = r.json() 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"] == [ assert body["merge"] == [
{"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}} {"context": "items", "slot": "items_list", "value": {"id": 42, "name": "renamed"}}
] ]
# invalidate stays empty when only merge is declared
assert body["invalidate"] == [] assert body["invalidate"] == []

View File

@@ -264,12 +264,28 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -300,6 +316,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"linkme", "linkme",
"minijinja",
"mizan-macros", "mizan-macros",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -1,6 +1,6 @@
//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's //! Render a `MizanError` as an axum `Response`: the JSON envelope
//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}` //! `{"error": {"code": ..., "message": ..., "details": ...}}` under a
//! with a Cache-Control: no-store header. //! `Cache-Control: no-store` header.
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; 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 { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.http_status()) let mut resp = (status_of(&self.0), Json(self.0.to_json())).into_response();
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp = (status, Json(self.0.to_json())).into_response();
resp.headers_mut() resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp resp

View File

@@ -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::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use mizan_core::{ use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec, compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, InvalidationTarget, MergeEntry, MizanError, Primitive, RequestHandle,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Number, Value};
use std::any::Any; use std::any::Any;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
@@ -21,24 +21,16 @@ use crate::errors::ApiError;
/// `Arc` keeps the clone cheap across per-request handler invocations. /// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>; pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// Body for POST /call/. Matches the Python `CallBody` shape. /// Body for POST /call/.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CallBody { pub struct CallBody {
pub fn_: Option<String>, /// `fn` is a Rust keyword, hence the serde rename.
#[serde(rename = "fn")] #[serde(rename = "fn")]
pub function_name: Option<String>, pub function_name: String,
#[serde(default)] #[serde(default)]
pub args: Map<String, Value>, 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)] #[derive(Debug, Serialize)]
pub struct CallResponse { pub struct CallResponse {
pub result: Value, pub result: Value,
@@ -47,28 +39,37 @@ pub struct CallResponse {
pub merge: Option<Vec<Value>>, pub merge: Option<Vec<Value>>,
} }
fn no_store(json: Value) -> Response { fn no_store<T: Serialize>(body: T) -> Response {
let mut resp = (StatusCode::OK, Json(json)).into_response(); let mut resp = (StatusCode::OK, Json(body)).into_response();
resp.headers_mut() resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp 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( pub async fn function_call(
State(app_state): State<AppStateAny>, State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>, Json(body): Json<CallBody>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let fn_name = body let registered = function_named(&body.function_name);
.resolved_name() let fn_spec = match registered.as_slice() {
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? [] => {
.to_string(); return Err(ApiError(MizanError::NotFound(format!(
"function {:?} not registered",
let fn_spec = lookup_function(&fn_name) body.function_name
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?; ))))
}
[fn_spec, ..] => *fn_spec,
};
let req = RequestHandle::from_dyn(app_state.as_ref()); 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) let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter() .iter()
@@ -81,82 +82,86 @@ pub async fn function_call(
Some(merges.iter().map(MergeEntry::to_json).collect()) Some(merges.iter().map(MergeEntry::to_json).collect())
}; };
let payload = CallResponse { Ok(no_store(CallResponse {
result, result,
invalidate, invalidate,
merge: merge_payload, 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( pub async fn context_fetch(
State(app_state): State<AppStateAny>, State(app_state): State<AppStateAny>,
Path(context_name): Path<String>, Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>, Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
if lookup_context(&context_name).is_none() { let members = context_members(&context_name);
let selected = match members.as_slice() {
[] => {
return Err(ApiError(MizanError::NotFound(format!( return Err(ApiError(MizanError::NotFound(format!(
"context {context_name:?} not registered" "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(); let mut bundled = Map::new();
for fn_spec in &members { for fn_spec in selected {
let args = coerce_query_args(*fn_spec, &params); let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref()); let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?; match fn_spec.dispatch(req, Value::Object(args)).await {
Ok(result) => {
bundled.insert(fn_spec.name().to_string(), result); bundled.insert(fn_spec.name().to_string(), result);
} }
Err(e) => return Err(ApiError(e)),
}
}
Ok(no_store(Value::Object(bundled))) Ok(no_store(Value::Object(bundled)))
} }
/// Coerce string-valued query params into typed JSON values using the /// A query string carries every value as text, so each declared input param
/// function's declared input_params. Strings that don't parse stay as /// reads its raw text as the primitive it declares. Text spelling something
/// strings — the dispatch wrapper will raise ValidationFailed downstream. /// 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 coerce_query_args(
fn_spec: &dyn FunctionSpec, fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>, params: &BTreeMap<String, String>,
) -> Map<String, Value> { ) -> Map<String, Value> {
let mut out = Map::new(); let mut out = Map::new();
for ip in fn_spec.input_params() { for ip in fn_spec.input_params() {
if let Some(raw) = params.get(ip.name) { for (_, raw) in params.iter().filter(|(name, _)| name.as_str() == ip.name) {
let parsed = match ip.primitive { let as_text = Value::from(raw.clone());
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from), let coerced = match ip.primitive {
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| { Primitive::String => as_text,
serde_json::Number::from_f64(v).map(Value::Number) Primitive::Boolean => match raw.as_str() {
}), "true" => Value::Bool(true),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from), "false" => Value::Bool(false),
mizan_core::Primitive::String => Some(Value::from(raw.clone())), _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(), coerced);
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
} }
} }
out out
} }
/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint. /// GET /session/ — emits `{"csrfToken": null}`.
/// CSRF is a Django-only concern; the Rust adapter returns a null token so
/// readiness-probe consumers see a well-formed response.
pub async fn session_init() -> Response { pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null }); no_store(serde_json::json!({ "csrfToken": null }))
no_store(body)
} }

View File

@@ -13,8 +13,8 @@
//! } //! }
//! ``` //! ```
//! //!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`): //! Exposed endpoints:
//! * `GET /session/` — session-init probe (placeholder CSRF token) //! * `GET /session/` — session-init probe
//! * `POST /call/` — RPC dispatch with invalidate+merge response //! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch //! * `GET /ctx/:name/` — bundled context fetch
@@ -51,8 +51,7 @@ where
} }
/// Router variant for callers that have no app state to thread — the /// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture /// dispatch path receives a unit-typed handle.
/// and other stateless test apps.
pub fn router_stateless() -> Router { pub fn router_stateless() -> Router {
router(()) router(())
} }

View File

@@ -1747,6 +1747,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.9.1" version = "0.9.1"
@@ -1762,6 +1768,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -1789,6 +1805,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"linkme", "linkme",
"minijinja",
"mizan-macros", "mizan-macros",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -1,21 +1,17 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC. //! 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 //! ```ignore
//! tauri::Builder::default() //! tauri::Builder::default()
//! .plugin(mizan_tauri::init()) //! .plugin(mizan_tauri::init())
//! .run(tauri::generate_context!()) //! .run(tauri::generate_context!())
//! .expect("error while running tauri application");
//! ``` //! ```
//! //!
//! The plugin exposes a single command `mizan_invoke` (full Tauri name //! The plugin exposes a single command `mizan_invoke` (full Tauri name
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport` //! `plugin:mizan|mizan_invoke`), which routes through `mizan-core`'s
//! sends call/fetch envelopes to it; the dispatch routes through //! FUNCTIONS / CONTEXTS registries. There is no per-function
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same //! `tauri::command`; the registry IS the dispatch table.
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
//! consumes. There is no per-function tauri::command; the registry IS
//! the dispatch table.
//! //!
//! Wire envelope: //! Wire envelope:
//! //!
@@ -24,23 +20,20 @@
//! { "op": "fetch", "context": "session", "params": {} } //! { "op": "fetch", "context": "session", "params": {} }
//! ``` //! ```
//! //!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from //! Response shapes:
//! mizan-rust-axum:
//! //!
//! * `call` → `{ result, invalidate, merge? }` //! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle) //! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! //!
//! Error responses come back as the `Err` variant of the Tauri command's //! Errors come back as the `Err` variant of the command's `Result`, which
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`. //! 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.
use mizan_core::{ use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function, compute_invalidation, compute_merges, context_members, function_named, FunctionSpec,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, InvalidationTarget, MergeEntry, MizanError, RequestHandle,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{Map, Value};
use tauri::{ use tauri::{
plugin::{Builder, TauriPlugin}, plugin::{Builder, TauriPlugin},
Runtime, Runtime,
@@ -79,9 +72,8 @@ pub enum Envelope {
}, },
} }
/// Error payload returned to the frontend. Mirrors the HTTP adapter's /// Error payload returned to the frontend. The JS-side transport reads
/// `{"code", "message", "details?"}` shape; the TS-side transport reads /// `code` / `message` / `details` and constructs a `MizanError`.
/// this and constructs a `MizanError`.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ErrorPayload { pub struct ErrorPayload {
pub code: &'static str, pub code: &'static str,
@@ -114,6 +106,11 @@ impl From<MizanError> for ErrorPayload {
/// it into a `RequestHandle` so `#[mizan::client]` functions can /// it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event /// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
/// emission. Stateless functions ignore the handle. /// 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] #[tauri::command]
async fn mizan_invoke<R: Runtime>( async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>, app: tauri::AppHandle<R>,
@@ -123,98 +120,79 @@ async fn mizan_invoke<R: Runtime>(
Envelope::Call { Envelope::Call {
function_name, function_name,
args, 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,
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)
.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 mut payload = json!({ let req = RequestHandle::new(&app);
"result": result, match fn_spec.dispatch(req, Value::Object(args.clone())).await {
"invalidate": invalidate, Ok(result) => Ok(call_payload(fn_spec, &args, result)),
}); Err(e) => Err(ErrorPayload::from(e)),
if let Some(merge) = merge_payload {
payload
.as_object_mut()
.expect("payload is a JSON object")
.insert("merge".into(), Value::Array(merge));
} }
Ok(payload) }
} Envelope::Fetch { context, params } => {
let members = context_members(&context);
async fn handle_fetch<R: Runtime>( let selected = match members.as_slice() {
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!( return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} not registered" "context {context:?} names no registered functions"
)))); ))))
}
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"
))));
} }
selected => selected,
};
let mut bundled = Map::new(); let mut bundled = Map::new();
for fn_spec in &members { for fn_spec in selected {
let args = filter_args(*fn_spec, &params); let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(app); let req = RequestHandle::new(&app);
let result = fn_spec match fn_spec.dispatch(req, Value::Object(args)).await {
.dispatch(req, Value::Object(args)) Ok(result) => {
.await
.map_err(ErrorPayload::from)?;
bundled.insert(fn_spec.name().to_string(), result); bundled.insert(fn_spec.name().to_string(), result);
} }
Err(e) => return Err(ErrorPayload::from(e)),
}
}
Ok(Value::Object(bundled)) Ok(Value::Object(bundled))
}
}
} }
/// Filter the envelope's params down to keys this function declares as /// The `call` response body — the handler's result alongside the
/// input. The HTTP/axum adapter coerces string-typed query params to /// invalidation targets and merge entries the registry derives from the
/// JSON primitives in the equivalent step; the Tauri arg channel already /// arguments and that result.
/// carries typed JSON, so the filter is sufficient on its own. fn call_payload(fn_spec: &dyn FunctionSpec, args: &Map<String, Value>, result: Value) -> Value {
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> { let invalidate: Vec<Value> = compute_invalidation(fn_spec, args)
let mut out = Map::new(); .iter()
for ip in fn_spec.input_params() { .map(InvalidationTarget::to_json)
if let Some(v) = params.get(ip.name) { .collect();
out.insert(ip.name.into(), v.clone()); let merges = compute_merges(fn_spec, args, &result);
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));
} }
} Value::Object(payload)
out }
/// 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 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()
} }

View File

@@ -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 { export interface CacheBackend {
get(key: string): string | null get(key: string): string | null
set(key: string, value: string): void set(key: string, value: string): void
@@ -29,6 +29,7 @@ export class MemoryCache implements CacheBackend {
deleteByPrefix(prefix: string): number { deleteByPrefix(prefix: string): number {
let count = 0 let count = 0
// Snapshot the keys — deleting while iterating the live view is UB.
for (const key of [...this._store.keys()]) { for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {
this._store.delete(key) this._store.delete(key)

View File

@@ -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 { MemoryCache } from './backend'
export type { CacheBackend } from './backend' export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys' export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
@@ -52,6 +44,13 @@ export function cachePut(
backend.set(key, value) 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( export function cachePurge(
backend: CacheBackend, backend: CacheBackend,
context: string, context: string,
@@ -61,11 +60,9 @@ export function cachePurge(
rev: number = 0, rev: number = 0,
): number { ): number {
if (params && secret) { if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev) const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0 return backend.delete(key) ? 1 : 0
} else { } else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:` const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix) return backend.deleteByPrefix(prefix)
} }

View File

@@ -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. * Key format: "ctx:{context}:{hmac_hex}". The context prefix is what lets a
* Cross-language conformance verified by pin tests. * broad purge run as a prefix scan over the backend's keyspace.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
*/ */
import { createHmac } from 'crypto' import { createHmac } from 'crypto'
@@ -13,7 +11,11 @@ const CONTEXT_KEY_PREFIX = 'ctx:'
/** /**
* JSON.stringify with recursively sorted keys and no whitespace. * 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 { function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null' if (obj === null || obj === undefined) return 'null'

View File

@@ -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 { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry' 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[] { function extractParams(fn: Function): ParamDef[] {
// Extract parameter names from function.toString()
const source = fn.toString() const source = fn.toString()
const match = source.match(/\(([^)]*)\)/) const match = source.match(/\(([^)]*)\)/)
if (!match || !match[1].trim()) return [] if (!match || !match[1].trim()) return []
@@ -46,33 +37,22 @@ function extractParams(fn: Function): ParamDef[] {
.map(p => p.trim()) .map(p => p.trim())
.filter(p => p && !p.startsWith('...')) .filter(p => p && !p.startsWith('...'))
.map(p => { .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() const name = p.split(/[=:]/)[0].trim()
return { name, type: 'any', required: !p.includes('=') } return { name, type: 'any', required: !p.includes('=') }
}) })
} }
/** /** Wrap and register a standalone function. */
* Function wrapper — registers a standalone function.
*
* const userProfile = client({ context: UserCtx }, async (userId: number) => { ... })
*/
export function client<T extends (...args: any[]) => Promise<any>>( export function client<T extends (...args: any[]) => Promise<any>>(
options: ClientOptions, options: ClientOptions,
fn: T, fn: T,
): T ): T
/** /** Register a class method. */
* Class method decorator.
*
* class Handlers {
* @client({ context: UserCtx })
* async userProfile(userId: number) { ... }
* }
*/
export function client(options: ClientOptions): MethodDecorator 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) // Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') { if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions const options = optionsOrFn as ClientOptions
@@ -85,16 +65,17 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
const name = fn.name || 'anonymous' const name = fn.name || 'anonymous'
const params = extractParams(fn) const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = { const entry: RegistryEntry = {
name, name,
fn: fn as any, fn: fn as RegistryEntry['fn'],
context, context,
affects, affects,
params, params,
private: options.private ?? false, 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, route: options.route,
methods: options.methods, methods: options.methods,
auth: options.auth, auth: options.auth,

View File

@@ -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 * Handlers return plain MizanResponse objects; turning one into a
* (Express, Hono, etc.) converts to framework-specific responses. * framework's own response type is the router adapter's job.
*/ */
import { getFunction, getContextGroups } from './registry' 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' }, 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> = {} const results: Record<string, any> = {}
@@ -86,6 +89,7 @@ export async function handleContextFetch(
results[fnName] = result results[fnName] = result
} catch (e: any) { } catch (e: any) {
console.error(`mizan: context function '${fnName}' raised`, e)
return { return {
status: 500, status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' }, body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },
@@ -111,7 +115,10 @@ export async function handleContextFetch(
if (cacheBackend && cacheSecret && effectiveCache !== false) { if (cacheBackend && cacheSecret && effectiveCache !== false) {
try { try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev) 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 { return {
@@ -186,20 +193,25 @@ export async function handleMutationCall(
// Purge origin-side cache // Purge origin-side cache
const cb = getCache() const cb = getCache()
if (cb) { if (cb) {
for (const target of invalidate) {
try { try {
for (const entry of invalidate) { if (typeof target === 'string') {
if (typeof entry === 'string') { cachePurge(cb, target)
cachePurge(cb, entry)
} else { } 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 } return { status: 200, body: responseData, headers }
} catch (e: any) { } catch (e: any) {
console.error(`mizan: mutation '${fnName}' raised`, e)
return { return {
status: 500, status: 500,
body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' }, body: { error: true, code: 'INTERNAL_ERROR', message: 'Internal error' },

View File

@@ -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 type { RegistryEntry } from './types'
import { getContextGroups, getContextParamNames, getFunction } from './registry' import { getContextGroups, getContextParamNames } from './registry'
type InvalidateEntry = string | { context: string; params: Record<string, any> } 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 * Each target narrows to the call arguments whose names the target's context
* Tier 2: Auth inference (Edge-side, not handled here) * also declares as params — that intersection is the scoped purge. A target
* Tier 3: Broad fallback * with no such overlap emits as a bare context name, meaning purge every
* entry under that context.
*/ */
export function resolveInvalidation( export function resolveInvalidation(
entry: RegistryEntry, entry: RegistryEntry,
@@ -34,7 +29,6 @@ export function resolveInvalidation(
const resolved = resolveAffectsTarget(targetName) const resolved = resolveAffectsTarget(targetName)
const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name const ctxForParams = resolved.type === 'function' ? resolved.context : resolved.name
// Tier 1: argument name matching
if (callArgs && ctxForParams) { if (callArgs && ctxForParams) {
const contextParams = getContextParamNames(ctxForParams) const contextParams = getContextParamNames(ctxForParams)
const matched: Record<string, any> = {} const matched: Record<string, any> = {}
@@ -47,7 +41,6 @@ export function resolveInvalidation(
} }
} }
// Tier 3: broad fallback
result.push(targetName) 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 } { function resolveAffectsTarget(name: string): { type: 'context' | 'function'; name: string; context?: string } {
const groups = getContextGroups() 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 { export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const parts: string[] = [] const parts: string[] = []

View File

@@ -1,15 +1,8 @@
/** import type { EdgeManifest, ManifestFunction } from './types'
* 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 { getAllFunctions, getContextGroups, getContextParamNames } from './registry' import { getAllFunctions, getContextGroups, getContextParamNames } from './registry'
// Both camelCase and snake_case forms included for cross-language matching. // Wire format is snake_case; camelCase is the TS-local convention. Both forms
// Wire format is snake_case (protocol rule); camelCase is the TS-local convention. // 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']) const USER_SCOPED_PARAMS = new Set(['userId', 'user', 'ownerId', 'accountId', 'user_id', 'owner_id', 'account_id'])
export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest { export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
@@ -20,7 +13,7 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
// Contexts // Contexts
for (const [ctxName, fnNames] of Object.entries(groups)) { for (const [ctxName, fnNames] of Object.entries(groups)) {
const paramNames = new Set<string>() const paramNames = new Set<string>()
const functions: Array<{ name: string; path: 'rpc' | 'view'; route?: string; methods?: string[] }> = [] const functions: ManifestFunction[] = []
const pageRoutes: string[] = [] const pageRoutes: string[] = []
for (const fnName of fnNames) { 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) 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) { if (entry.route) {
fnEntry.route = entry.route fnEntry.route = entry.route
fnEntry.methods = entry.methods || ['GET'] fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route) pageRoutes.push(entry.route)
} }
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev 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) functions.push(fnEntry)
} }

View File

@@ -1,12 +1,10 @@
/**
* Mizan Registry — Central registration for server functions.
*/
import type { RegistryEntry } from './types' import type { RegistryEntry } from './types'
const _functions: Map<string, RegistryEntry> = new Map() const _functions: Map<string, RegistryEntry> = new Map()
export function register(entry: RegistryEntry): void { 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) { if (_functions.has(entry.name) && _functions.get(entry.name)!.fn !== entry.fn) {
throw new Error(`Function '${entry.name}' already registered`) throw new Error(`Function '${entry.name}' already registered`)
} }

View File

@@ -1,7 +1,3 @@
/**
* Mizan TypeScript Adapter — Shared Types
*/
export class ReactContext { export class ReactContext {
constructor(public readonly name: string) { constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty') if (!name) throw new Error('ReactContext name must be non-empty')
@@ -42,8 +38,17 @@ export interface RegistryEntry {
cache?: number | false cache?: number | false
} }
export interface ManifestFunction {
name: string
path: 'rpc' | 'view'
route?: string
methods?: string[]
rev?: number
cache?: number | false
}
export interface ManifestContext { export interface ManifestContext {
functions: Array<{ name: string; path: 'rpc' | 'view' }> functions: ManifestFunction[]
endpoints: string[] endpoints: string[]
params: string[] params: string[]
user_scoped: boolean user_scoped: boolean

View File

@@ -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 { 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' import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
const UserCtx = new ReactContext('user') const UserCtx = new ReactContext('user')
function setupUserContext() { 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` } 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 } 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` } return { name, email: `user${userId}@test.com` }
}) })
@@ -122,7 +115,7 @@ describe('Edge Compatibility', () => {
{ context: 'data', params: { name: "O'Brien", tag: 'a;b;c' } }, { 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 segments = header.split(';')
const ctx = segments[0] const ctx = segments[0]
const params: Record<string, string> = {} const params: Record<string, string> = {}
@@ -139,12 +132,6 @@ describe('Edge Compatibility', () => {
// ── Empty invalidation ───────────────────────────────────────────── // ── Empty invalidation ─────────────────────────────────────────────
test('no affects = no header, no body key', async () => { 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() clearRegistry()
client({}, async function noAffects() { return { ok: true } }) client({}, async function noAffects() { return { ok: true } })
const r = await handleMutationCall('noAffects', {}) const r = await handleMutationCall('noAffects', {})
@@ -251,6 +238,18 @@ describe('Manifest', () => {
expect(fn.cache).toBe(60) 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 () => { test('cache=60 still emits no-store on HTTP', async () => {
clearRegistry() clearRegistry()
const Ctx = new ReactContext('live') const Ctx = new ReactContext('live')
@@ -294,8 +293,9 @@ describe('Cache Conformance', () => {
}) })
test('deriveCacheKey cross-language pin (matches Python)', () => { test('deriveCacheKey cross-language pin (matches Python)', () => {
// These exact values are pinned from Python's derive_cache_key output. // Literals captured from Python's derive_cache_key under the same
// If this test fails, cross-language cache key compatibility is broken. // 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) const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6') expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
@@ -362,6 +362,27 @@ describe('Cache Conformance', () => {
setCacheSecret(null) 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 () => { test('handleMutationCall purges cache', async () => {
clearRegistry() clearRegistry()
const Ctx = new ReactContext('product') const Ctx = new ReactContext('product')

View File

@@ -6,11 +6,14 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"PyJWT>=2.0", "PyJWT>=2.0",
"jinja2>=3.1",
"pydantic>=2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"ckdl>=1.0",
] ]
[build-system] [build-system]

View File

@@ -1,32 +1,36 @@
""" """Cache backends — a key/value store keyed by the derived HMAC cache key."""
Cache backends — MemoryCache (testing) and RedisCache (production).
Simple key-value stores. No reverse indexes. Cache keys are derived
from HMAC, so scoped purge just recomputes the key and deletes it.
Broad purge uses key-prefix scan (rare operation).
"""
from __future__ import annotations from __future__ import annotations
from typing import Protocol import abc
class CacheBackend(Protocol): class CacheBackend(abc.ABC):
"""Interface that all Mizan cache backends implement.""" """A key/value store holding serialized context payloads."""
def get(self, key: str) -> bytes | None: ... @abc.abstractmethod
def set(self, key: str, value: bytes) -> None: ... def get(self, key: str) -> bytes | None:
def delete(self, key: str) -> bool: ... """The stored value for `key`, or None when absent."""
def delete_by_prefix(self, prefix: str) -> int: ...
def clear(self) -> None: ... @abc.abstractmethod
def set(self, key: str, value: bytes) -> None:
"""Store `value` under `key`, replacing anything already there."""
@abc.abstractmethod
def delete(self, key: str) -> bool:
"""Drop `key`. True if it was present."""
@abc.abstractmethod
def delete_by_prefix(self, prefix: str) -> int:
"""Drop every key starting with `prefix`. Returns how many were dropped."""
@abc.abstractmethod
def clear(self) -> None:
"""Drop every key this backend owns."""
class MemoryCache: class MemoryCache(CacheBackend):
""" """A process-local dict. No persistence, no cross-process sharing."""
In-memory cache backend for testing.
Uses a Python dict. No persistence, no cross-process sharing.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._store: dict[str, bytes] = {} self._store: dict[str, bytes] = {}
@@ -53,13 +57,8 @@ class MemoryCache:
self._store.clear() self._store.clear()
class RedisCache: class RedisCache(CacheBackend):
""" """Redis GET/SET/UNLINK behind a key namespace, with SCAN for prefix drops."""
Redis-backed cache backend for production.
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
the HMAC key and deletes directly. Broad purge uses SCAN.
"""
DEFAULT_TTL = 86400 # 24h safety-net DEFAULT_TTL = 86400 # 24h safety-net
@@ -71,11 +70,11 @@ class RedisCache:
) -> None: ) -> None:
try: try:
import redis as redis_lib import redis as redis_lib
except ImportError: except ImportError as exc:
raise ImportError( raise ImportError(
"Redis is required for Mizan's cache backend. " "Redis is required for Mizan's cache backend. "
"Install it with: pip install mizan[cache]" "Install it with: pip install mizan[cache]"
) ) from exc
self._client = redis_lib.from_url( self._client = redis_lib.from_url(
redis_url, redis_url,
socket_connect_timeout=5, socket_connect_timeout=5,

View File

@@ -1,12 +1,4 @@
""" """Cache key derivation — HMAC-SHA256 over a canonical JSON form."""
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
Protocol-critical: every Mizan adapter must produce identical output
for identical inputs. Cross-language conformance verified by pin tests.
Scoped purge recomputes the key directly — no reverse index needed.
Broad purge uses a context prefix scan.
"""
from __future__ import annotations from __future__ import annotations
@@ -15,7 +7,6 @@ import hmac
import json import json
from typing import Any from typing import Any
# Context prefix for broad purge (SCAN pattern)
CONTEXT_KEY_PREFIX = "ctx:" CONTEXT_KEY_PREFIX = "ctx:"
@@ -33,8 +24,9 @@ def derive_cache_key(
broad purge can SCAN by prefix "ctx:{context}:*". broad purge can SCAN by prefix "ctx:{context}:*".
""" """
def _normalize(v: Any) -> str: def _normalize(v: Any) -> str:
"""Normalize values for cross-language HMAC consistency. """Render a param value in its JSON-native spelling.
Python str(True)="True" but JS String(true)="true". Use JSON-native forms.""" Python str(True) is "True" but JS String(true) is "true", and the two
must hash identically."""
if v is True: if v is True:
return "true" return "true"
if v is False: if v is False:

View File

@@ -1,16 +1,14 @@
""" """
mizan Server Functions - Core Primitive Server functions: the `@client` decorator and the `ServerFunction` class it
produces, `ReactContext` for grouping them, and `compose` for combining
contexts into one provider.
Server functions are the core primitive. Everything else builds on them. Function form:
@client
Two styles supported: def update_profile(request, name: str) -> UpdateProfileOutput:
1. Function-based (recommended, Django Ninja style):
@client("update-profile")
def update_profile(request, input: UpdateProfileInput) -> UpdateProfileOutput:
return UpdateProfileOutput(success=True) return UpdateProfileOutput(success=True)
2. Class-based (for complex cases): Class form:
class UpdateProfile(ServerFunction): class UpdateProfile(ServerFunction):
def call(self, input: UpdateProfileInput) -> UpdateProfileOutput: def call(self, input: UpdateProfileInput) -> UpdateProfileOutput:
return UpdateProfileOutput(success=True) return UpdateProfileOutput(success=True)
@@ -21,7 +19,6 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import ( from typing import (
Any, Any,
@@ -30,24 +27,16 @@ from typing import (
Generic, Generic,
Literal, Literal,
TypeVar, TypeVar,
Union,
get_args,
get_origin,
get_type_hints, get_type_hints,
) )
from pydantic import BaseModel from pydantic import BaseModel
# ─── Framework-response-base hook ───────────────────────────────────────────
#
# View-path detection — distinguishing functions that return data (RPC path) # View-path detection — distinguishing functions that return data (RPC path)
# from functions that return a framework-native response object (view path) — # from functions that return a framework-native response object (view path) —
# requires knowing the framework's response base class. Each backend adapter # needs the framework's response base class, which only the backend adapter
# registers its base class here at import time. # knows. While it is unset, every function is treated as RPC.
#
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
# starlette.responses.Response. If unset, all functions are treated as RPC.
_framework_response_base: type | None = None _framework_response_base: type | None = None
@@ -101,7 +90,7 @@ class ReactContext:
return f"ReactContext({self.name!r})" return f"ReactContext({self.name!r})"
# Built-in global context (auto-mounted at root, SSR-hydrated) # The context named 'global', pre-made so callers share one instance of it.
GlobalContext = ReactContext("global") GlobalContext = ReactContext("global")
@@ -209,7 +198,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
class _FunctionWrapper(ServerFunction): class _FunctionWrapper(ServerFunction):
"""Internal wrapper that makes a plain function behave like a ServerFunction.""" """Internal wrapper that makes a plain function behave like a ServerFunction."""
# Will be set per-wrapper instance # Set per-wrapper subclass by _create_server_function
_wrapped_fn: ClassVar[Callable] _wrapped_fn: ClassVar[Callable]
_input_cls: ClassVar[type[BaseModel] | None] _input_cls: ClassVar[type[BaseModel] | None]
_output_cls: ClassVar[type[BaseModel]] _output_cls: ClassVar[type[BaseModel]]
@@ -284,12 +273,6 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
if isinstance(context, str): if isinstance(context, str):
if not context.strip(): if not context.strip():
raise ValueError("context must be a non-empty string, ReactContext, or False.") raise ValueError("context must be a non-empty string, ReactContext, or False.")
if context == "local":
warnings.warn(
"context='local' is deprecated. Use ReactContext('name') instead.",
DeprecationWarning,
stacklevel=3,
)
return context return context
raise ValueError( raise ValueError(
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}." f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
@@ -327,7 +310,6 @@ def client(
context: Named context for React state management. context: Named context for React state management.
- False (default): Not a context, just a callable function. - False (default): Not a context, just a callable function.
- ReactContext instance: groups functions into a named context. - ReactContext instance: groups functions into a named context.
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
affects: Declare which contexts or functions this mutation invalidates. affects: Declare which contexts or functions this mutation invalidates.
Mutually exclusive with context=. Mutually exclusive with context=.
@@ -526,8 +508,8 @@ def _create_server_function(
is_view_path = is_framework_response(output_type) is_view_path = is_framework_response(output_type)
if is_view_path: if is_view_path:
# View path — no Pydantic output wrapping needed # A view path serializes nothing, so Output is never read off this class.
output_cls = BaseModel # placeholder, never used for serialization output_cls = BaseModel
is_primitive_output = False is_primitive_output = False
else: else:
# RPC path — resolve output type # RPC path — resolve output type
@@ -555,7 +537,7 @@ def _create_server_function(
FunctionWrapper._output_cls = output_cls FunctionWrapper._output_cls = output_cls
FunctionWrapper._is_primitive_output = is_primitive_output FunctionWrapper._is_primitive_output = is_primitive_output
# Set Input/Output class attributes for compatibility # Input/Output are the names the ServerFunction contract exposes them under
if input_cls is not None: if input_cls is not None:
FunctionWrapper.Input = input_cls FunctionWrapper.Input = input_cls
FunctionWrapper.Output = output_cls FunctionWrapper.Output = output_cls
@@ -615,8 +597,8 @@ def _create_server_function(
# Always assign a fresh dict to prevent shared-dict mutation across classes # Always assign a fresh dict to prevent shared-dict mutation across classes
FunctionWrapper._meta = {**meta} FunctionWrapper._meta = {**meta}
# Note: Registration happens via discovery (mizan_clients), not here. # Registration happens via discovery (mizan_clients), so the decorator has
# This allows the decorator to be used without import-time side effects. # no import-time side effects.
return FunctionWrapper return FunctionWrapper
@@ -657,15 +639,14 @@ class ComposedContext:
"leaves": [leaf.name for leaf in leaves], "leaves": [leaf.name for leaf in leaves],
} }
@classmethod def get_schema_export(self) -> dict[str, Any]:
def get_schema_export(cls) -> dict[str, Any]:
"""Export schema for TypeScript generation.""" """Export schema for TypeScript generation."""
return { return {
"name": cls.name, "name": self.name,
"type": "compose", "type": "compose",
"meta": cls._meta, "meta": self._meta,
"children": cls._meta.get("children", []), "children": self._meta["children"],
"leaves": cls._meta.get("leaves", []), "leaves": self._meta["leaves"],
} }
@@ -676,7 +657,6 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
elif isinstance(item, ComposedContext): elif isinstance(item, ComposedContext):
return item._leaves.copy() return item._leaves.copy()
elif hasattr(item, "_leaves"): elif hasattr(item, "_leaves"):
# Duck typing for composed contexts
return item._leaves.copy() return item._leaves.copy()
else: else:
raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}") raise TypeError(f"Expected ServerFunction or ComposedContext, got {type(item)}")
@@ -714,21 +694,18 @@ def compose(
- True: Bundled call over WebSocket. All children must have websocket=True. - True: Bundled call over WebSocket. All children must have websocket=True.
Usage: Usage:
@client(context='local') UserContext = ReactContext('user')
@client(context=UserContext)
def user_profile(request, user_id: int) -> ProfileOutput: ... def user_profile(request, user_id: int) -> ProfileOutput: ...
@client(context='local') @client(context=UserContext)
def user_posts(request, user_id: int) -> PostsOutput: ... def user_posts(request, user_id: int) -> PostsOutput: ...
@compose(user_profile, user_posts) @compose(user_profile, user_posts)
def user_page(): def user_page():
pass pass
# Frontend generates:
# <UserPageProvider user_id={123}>
# <App />
# </UserPageProvider>
Nesting: Nesting:
@compose(ctx_a, ctx_b) @compose(ctx_a, ctx_b)
def ab(): pass def ab(): pass
@@ -767,7 +744,7 @@ def compose(
if id(leaf) in seen: if id(leaf) in seen:
raise ValueError( raise ValueError(
f"Duplicate context '{leaf.name}' in @compose({name}). " f"Duplicate context '{leaf.name}' in @compose({name}). "
f"Each context can only appear once. Use named kwargs for reuse (future feature)." f"Each context appears at most once across the flattened children."
) )
seen.add(id(leaf)) seen.add(id(leaf))

View File

@@ -1,37 +1,37 @@
""" """
Mizan IR — KDL emission from the live `mizan_core.registry`. Mizan IR — KDL emission from the live `mizan_core.registry`.
`build_ir()` walks every registered function class, introspects its `build_ir()` walks every registered function class, introspects its Pydantic
Pydantic Input/Output models directly (not via JSON-Schema), and emits Input/Output models directly (not via JSON-Schema), computes a plain-data
KDL — the canonical Mizan protocol IR. Every backend adapter exposes document, and renders it through `templates/ir/document.kdl.j2`.
this via a backend-specific entry point (Django management command,
FastAPI CLI, mizan-ts equivalent); every codegen target consumes this.
KDL grammar — locked contract: KDL grammar:
type "<Name>" { type "<Name>" {
struct { struct {
field "<name>" required=#true|#false default=<lit> { field "<name>" required=#false default=<lit> {
primitive "integer|number|boolean|string" <type-child>
| ref "<TypeName>"
| list { <type-child> }
| optional { <type-child> }
| enum "<v1>" "<v2>" ...
} }
... ...
} }
| list { <type-child> }
| enum "<v1>" "<v2>" ...
| alias { <type-child> } | alias { <type-child> }
} }
<type-child> =
primitive "integer|number|boolean|string"
| ref "<TypeName>"
| enum "<v1>" "<v2>" ...
| list { <type-child> }
| optional { <type-child> }
| union { <type-child> ... }
function "<wire_name>" { function "<wire_name>" {
camel "<camelCase>" camel "<camelCase>"
has-input #true|#false has-input #true|#false
input "<TypeName>" // omitted if has-input=#false input "<TypeName>" // omitted if has-input=#false
output "<TypeName>" output "<TypeName>"
output-nullable #true|#false // omitted when #false (default) output-nullable #true // omitted when #false (default)
transport "http"|"websocket"|"both" transport "http"|"websocket"
context "<ctx_name>" // omitted unless context-grouped context "<ctx_name>" // omitted unless context-grouped
affects "<ctx_name>" // 0..N occurrences affects "<ctx_name>" // 0..N occurrences
merge "<ctx_name>" // 0..N occurrences merge "<ctx_name>" // 0..N occurrences
@@ -51,44 +51,58 @@ KDL grammar — locked contract:
} }
} }
channel "<name>" { channel "<wire_name>" {
pascal-name "<PascalCase>" pascal-name "<PascalCase>"
params "<TypeName>" // omitted if no params params "<TypeName>" // omitted when the channel takes no params
react-message "<TypeName>" // omitted if no react message client-message "<TypeName>" // client -> server; omitted if none
django-message "<TypeName>" // omitted if no django message server-message "<TypeName>" // server -> client; omitted if none
} }
Nothing else lives in the IR. OpenAPI envelope, JSON-Schema $ref dance, Channel slots are named from the client's point of view — `client-message`
the Pydantic→json-schema converter — all gone. travels up, `server-message` travels down — and their type names are
`<Pascal>Params`, `<Pascal>ClientMessage` and `<Pascal>ServerMessage`, where
`<Pascal>` comes from `wire_to_pascal`. Backends that publish channel types
into their own schema documents call `wire_to_pascal` rather than deriving a
second Pascal form.
""" """
from __future__ import annotations from __future__ import annotations
import re
import types import types
from typing import Any, Literal, Union, get_args, get_origin from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel from jinja2 import Environment, PackageLoader, StrictUndefined
from pydantic import BaseModel, create_model
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined
from mizan_core.registry import get_all_functions, get_context_groups, get_function from mizan_core.registry import (
get_all_functions,
get_context_groups,
get_function,
get_registry,
)
from mizan_core.type_utils import extract_list_element, extract_optional from mizan_core.type_utils import extract_list_element, extract_optional
__all__ = ["build_ir"] __all__ = ["build_ir", "wire_to_pascal"]
# Common user-identity param names; mirrors the equivalent in mizan-django / # ─── Wire-name derivations ──────────────────────────────────────────────────
# mizan-fastapi schema-export logic.
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
# ─── KDL value formatting ─────────────────────────────────────────────────── def wire_to_pascal(wire_name: str) -> str:
"""The PascalCase stem every emitted type name for `wire_name` is built on."""
return "".join(part.title() for part in re.split(r"[._-]", wire_name))
def _kdl_string(s: str) -> str: # ─── KDL value encoding ─────────────────────────────────────────────────────
"""KDL-escape a string and wrap in quotes."""
def _kdl(value: str) -> str:
"""KDL-escape a string and wrap it in quotes."""
escaped = ( escaped = (
s.replace("\\", "\\\\") value.replace("\\", "\\\\")
.replace("\"", "\\\"") .replace("\"", "\\\"")
.replace("\n", "\\n") .replace("\n", "\\n")
.replace("\r", "\\r") .replace("\r", "\\r")
@@ -97,257 +111,114 @@ def _kdl_string(s: str) -> str:
return f'"{escaped}"' return f'"{escaped}"'
def _kdl_bool(b: bool) -> str: def _kdlbool(value: bool) -> str:
return "#true" if b else "#false" return "#true" if value else "#false"
def _kdl_value(v: Any) -> str: _ENV = Environment(
"""Render a JSON-shape Python value as a KDL literal.""" loader=PackageLoader("mizan_core", "templates"),
if v is None: undefined=StrictUndefined,
return "#null" keep_trailing_newline=True,
if v is True or v is False: trim_blocks=True,
return _kdl_bool(v) lstrip_blocks=True,
if isinstance(v, (int, float)): )
return repr(v) _ENV.filters["kdl"] = _kdl
if isinstance(v, str): _ENV.filters["kdlbool"] = _kdlbool
return _kdl_string(v)
# Fallback for compound values — defaults aren't typed in our IR.
import json
return _kdl_string(json.dumps(v))
# ─── KDL Builder ──────────────────────────────────────────────────────────── def _default_literal(value: Any) -> dict[str, Any] | None:
"""Tag a field default so the template can pick its KDL literal form.
A KDL entry value is a scalar, so the literal forms are exactly bool,
number and string. A default of any other shape — a nested model, a list,
a dict, an enum member — has no scalar form and yields `None`: the field
emits `required=#false` with no `default`, and the server-side Pydantic
model stays the authority for the value it fills in.
"""
if value is True or value is False:
return {"kind": "bool", "value": value}
if isinstance(value, (int, float)):
return {"kind": "number", "value": value}
if isinstance(value, str):
return {"kind": "string", "value": value}
return None
class _Block: # ─── Type shapes ────────────────────────────────────────────────────────────
"""Open-children context for a KDL node. Tracks indent level."""
__slots__ = ("lines", "indent")
def __init__(self, lines: list[str], indent: int):
self.lines = lines
self.indent = indent
def _prefix(self) -> str:
return " " * self.indent
def node(self, name: str, *args: str, **props: str) -> "_OpenNode":
"""Open a node. `args` are positional KDL args; `props` are key=value pairs."""
return _OpenNode(self.lines, self.indent, name, list(args), dict(props))
def leaf(self, name: str, *args: str, **props: str) -> None:
"""Emit a leaf node — no children block."""
parts = [name]
parts.extend(args)
for k, v in props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{self._prefix()}{' '.join(parts)}")
class _OpenNode: def _shape(annotation: Any, refs: list[type[BaseModel]]) -> dict[str, Any]:
"""A KDL node whose children are being built.""" """Reduce a Python annotation to a shape tree, appending every model it
references to `refs`."""
inner, is_optional = extract_optional(annotation)
if is_optional:
return {"kind": "optional", "of": _shape(inner, refs)}
def __init__(
self,
lines: list[str],
indent: int,
name: str,
args: list[str],
props: dict[str, str],
):
self.lines = lines
self.indent = indent
self.name = name
self.args = args
self.props = props
self._children_emitted = False
def __enter__(self) -> _Block:
parts = [self.name]
parts.extend(self.args)
for k, v in self.props.items():
parts.append(f"{k}={v}")
self.lines.append(f"{' ' * self.indent}{' '.join(parts)} {{")
self._children_emitted = True
return _Block(self.lines, self.indent + 1)
def __exit__(self, *_exc: Any) -> None:
if self._children_emitted:
self.lines.append(f"{' ' * self.indent}}}")
# ─── Type emission ──────────────────────────────────────────────────────────
def _emit_type_child(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None:
"""Emit the type-shape KDL for a Python annotation, recursing as needed."""
# Strip Optional[T] → emit `optional` wrapper.
inner, is_opt = extract_optional(annotation)
if is_opt:
with block.node("optional") as inner_block:
_emit_type_child(inner_block, inner, named_types)
return
# Multi-arm union (T | U) — emit `union { <each-branch> }`.
origin = get_origin(annotation) origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType): if origin is Union or isinstance(annotation, types.UnionType):
branches = [a for a in get_args(annotation) if a is not type(None)] branches = [a for a in get_args(annotation) if a is not type(None)]
if len(branches) > 1: if len(branches) > 1:
with block.node("union") as inner_block: return {
for branch in branches: "kind": "union",
_emit_type_child(inner_block, branch, named_types) "branches": [_shape(branch, refs) for branch in branches],
return }
# list[T] / tuple[T, ...] / set[T] / frozenset[T] → `list { ... }` element = extract_list_element(annotation)
elem = extract_list_element(annotation) if element is not None:
if elem is not None: return {"kind": "list", "of": _shape(element, refs)}
with block.node("list") as inner_block:
_emit_type_child(inner_block, elem, named_types)
return
# Literal[a, b, c] → enum
if origin is Literal: if origin is Literal:
args = get_args(annotation) values = get_args(annotation)
if all(isinstance(a, str) for a in args): if all(isinstance(v, str) for v in values):
quoted = " ".join(_kdl_string(a) for a in args) return {"kind": "enum", "values": list(values)}
block.lines.append(f"{block._prefix()}enum {quoted}")
return
# Pydantic model → reference by name.
if isinstance(annotation, type) and issubclass(annotation, BaseModel): if isinstance(annotation, type) and issubclass(annotation, BaseModel):
type_name = annotation.__name__ refs.append(annotation)
named_types.setdefault(type_name, _StructShape(annotation)) return {"kind": "ref", "name": annotation.__name__}
block.leaf("ref", _kdl_string(type_name))
return
# Primitives
if annotation is int: if annotation is int:
block.leaf("primitive", _kdl_string("integer")) return {"kind": "primitive", "name": "integer"}
return
if annotation is float: if annotation is float:
block.leaf("primitive", _kdl_string("number")) return {"kind": "primitive", "name": "number"}
return
if annotation is bool: if annotation is bool:
block.leaf("primitive", _kdl_string("boolean")) return {"kind": "primitive", "name": "boolean"}
return
if annotation is str:
block.leaf("primitive", _kdl_string("string"))
return
# Open-shape fallback (dict / Any / etc). # str, dict, Any and every other open shape collapse to string.
block.leaf("primitive", _kdl_string("string")) return {"kind": "primitive", "name": "string"}
def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]) -> None: def _struct_fields(
"""Emit `type "X" { alias { <type-child> } }` for a non-struct wrapper.""" model: type[BaseModel], refs: list[type[BaseModel]]
with block.node("alias") as alias_block: ) -> list[dict[str, Any]]:
_emit_type_child(alias_block, annotation, named_types) fields: list[dict[str, Any]] = []
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block:
for field_name, field_info in model.model_fields.items(): for field_name, field_info in model.model_fields.items():
props: dict[str, str] = {} # `is_required()` covers both the explicit Required marker and the
# `field_info.is_required()` checks both the explicit Required # presence of a default.
# marker and the presence of a default.
required = field_info.is_required() required = field_info.is_required()
if not required:
props["required"] = _kdl_bool(False)
default = field_info.default default = field_info.default
if default is not None and default is not PydanticUndefined and default is not ...: has_default = (
props["default"] = _kdl_value(default) not required
and default is not None
with struct_block.node("field", _kdl_string(field_name), **props) as field_block: and default is not PydanticUndefined
_emit_type_child(field_block, field_info.annotation, named_types) and default is not ...
)
fields.append(
{
"name": field_name,
"required": required,
"default": _default_literal(default) if has_default else None,
"shape": _shape(field_info.annotation, refs),
}
)
return fields
class _StructShape: # ─── Named types ────────────────────────────────────────────────────────────
"""A Pydantic BaseModel that emits as `type "X" { struct { ... } }`."""
__slots__ = ("model",)
def __init__(self, model: type[BaseModel]):
self.model = model
class _AliasShape: def _snake_to_camel(name: str) -> str:
"""A named alias wrapper — e.g. `<CamelName>Output = list[<Inner>]`.""" parts = name.replace(".", "_").replace("-", "_").split("_")
__slots__ = ("annotation",) return parts[0] + "".join(p.title() for p in parts[1:] if p)
def __init__(self, annotation: Any):
self.annotation = annotation
def _collect_named_types(functions: dict[str, Any]) -> dict[str, Any]:
"""First pass: collect every named type the IR's `function` section references.
Two kinds:
- Pydantic BaseModels seen anywhere in Input/Output traversal — emit
as `type "X" { struct { ... } }`.
- Function-output wrapper aliases (`<CamelName>Output = list[T]` /
`<CamelName>Output = T | None`) — emit as `type "X" { alias { ... } }`
so the consumer has a single named type to reference.
"""
seen: dict[str, Any] = {}
def visit_model(model: type[BaseModel]) -> None:
if model.__name__ in seen:
return
seen[model.__name__] = _StructShape(model)
for field_info in model.model_fields.values():
for nested in _nested_models(field_info.annotation):
visit_model(nested)
def visit_annotation(ann: Any) -> None:
for nested in _nested_models(ann):
visit_model(nested)
for fn_class in functions.values():
input_cls = getattr(fn_class, "Input", None)
if _has_input(input_cls):
input_named = _name_input_model(fn_class)
visit_model(input_named)
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
camel = _snake_to_camel(fn_class.name)
output_name = f"{camel}Output"
inner, _ = extract_optional(output_cls)
elem = extract_list_element(inner)
if elem is not None:
# `list[T]` (possibly wrapped in Optional) — emit a list alias.
# Visit the element type so its struct shape gets emitted too.
visit_annotation(output_cls)
if output_name not in seen:
seen[output_name] = _AliasShape(output_cls)
elif isinstance(inner, type) and issubclass(inner, BaseModel):
# `<Model>` or `Optional[<Model>]` — emit the model under the
# canonical name (rename if necessary).
output_named = _name_output_model(fn_class, inner)
visit_model(output_named)
# If the Optional wrapper differs from the bare model, emit an
# alias under the canonical output name too.
if output_named.__name__ != output_name:
seen.setdefault(output_name, _AliasShape(output_cls))
else:
# Primitive-wrapped output (`result: int`) — emit as alias.
seen.setdefault(output_name, _AliasShape(output_cls))
return seen
def _nested_models(annotation: Any) -> list[type[BaseModel]]:
"""All Pydantic models that appear anywhere inside `annotation`."""
out: list[type[BaseModel]] = []
inner, _ = extract_optional(annotation)
elem = extract_list_element(inner)
if elem is not None:
out.extend(_nested_models(elem))
return out
if isinstance(inner, type) and issubclass(inner, BaseModel):
out.append(inner)
return out
def _has_input(input_cls: Any) -> bool: def _has_input(input_cls: Any) -> bool:
@@ -359,206 +230,135 @@ def _has_input(input_cls: Any) -> bool:
) )
def _snake_to_camel(name: str) -> str:
parts = name.replace(".", "_").replace("-", "_").split("_")
return parts[0] + "".join(p.title() for p in parts[1:] if p)
def _name_input_model(fn_class: Any) -> type[BaseModel]: def _name_input_model(fn_class: Any) -> type[BaseModel]:
"""Return a copy of the function's Input model named `<CamelName>Input`.""" """The function's Input model under the canonical `<CamelName>Input` name."""
from pydantic import create_model canonical = f"{_snake_to_camel(fn_class.name)}Input"
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Input"
src = fn_class.Input src = fn_class.Input
if src.__name__ == canonical: if src.__name__ == canonical:
return src return src
# Re-derive under the canonical name so codegen consumers see a stable name.
return create_model(canonical, __base__=src) return create_model(canonical, __base__=src)
def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]: def _name_output_model(fn_class: Any, base: type[BaseModel]) -> type[BaseModel]:
"""Return a copy of the model named `<CamelName>Output`.""" """`base` under the canonical `<CamelName>Output` name."""
from pydantic import create_model canonical = f"{_snake_to_camel(fn_class.name)}Output"
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
if base.__name__ == canonical: if base.__name__ == canonical:
return base return base
return create_model(canonical, __base__=base) return create_model(canonical, __base__=base)
# ─── Function / context / channel emission ────────────────────────────────── def _bind(
sources: dict[str, tuple[str, Any]], name: str, source: tuple[str, Any]
) -> None:
"""Claim `name` for one declaration. Two different declarations under one
name would emit two `type` blocks that no `ref` can tell apart, so the
second claim raises."""
claimed = sources.setdefault(name, source)
if claimed != source:
raise ValueError(
f"named type '{name}' is claimed twice, by {claimed[1]!r} "
f"and by {source[1]!r}"
)
def _function_props(fn_class: Any, output_type_name: str, output_nullable: bool) -> dict[str, Any]: def _seed_named_types(
"""Collect every value that goes inside a `function` block.""" functions: dict[str, Any], channel_models: list[tuple[str, type[BaseModel]]]
) -> dict[str, tuple[str, Any]]:
"""Name → ("struct", model) | ("alias", annotation) for every type the
function and channel sections reference directly."""
seeds: dict[str, tuple[str, Any]] = {}
for fn_class in functions.values():
if _has_input(getattr(fn_class, "Input", None)):
named_input = _name_input_model(fn_class)
_bind(seeds, named_input.__name__, ("struct", named_input))
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
continue
output_name = f"{_snake_to_camel(fn_class.name)}Output"
inner, _ = extract_optional(output_cls)
wraps_model = (
extract_list_element(inner) is None
and isinstance(inner, type)
and issubclass(inner, BaseModel)
)
if wraps_model:
_bind(seeds, output_name, ("struct", _name_output_model(fn_class, inner)))
else:
_bind(seeds, output_name, ("alias", output_cls))
for type_name, model in channel_models:
_bind(seeds, type_name, ("struct", model))
return seeds
def _resolve_named_types(seeds: dict[str, tuple[str, Any]]) -> list[dict[str, Any]]:
"""Resolve seeds to a fixed point — resolving one type discovers the models
it references, which are themselves resolved — then order by name."""
sources = dict(seeds)
resolved: dict[str, dict[str, Any]] = {}
while True:
unresolved = [name for name in sources if name not in resolved]
if not unresolved:
break
for name in unresolved:
kind, payload = sources[name]
refs: list[type[BaseModel]] = []
if kind == "struct":
resolved[name] = {
"name": name,
"kind": "struct",
"fields": _struct_fields(payload, refs),
}
else:
resolved[name] = {
"name": name,
"kind": "alias",
"shape": _shape(payload, refs),
}
for model in refs:
_bind(sources, model.__name__, ("struct", model))
return [resolved[name] for name in sorted(resolved)]
# ─── Functions, contexts, channels ──────────────────────────────────────────
def _is_emitted(fn_class: Any) -> bool:
meta = getattr(fn_class, "_meta", {}) meta = getattr(fn_class, "_meta", {})
name = fn_class.name return not (meta.get("private") or meta.get("view_path"))
camel = _snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls) def _function_entry(fn_class: Any) -> dict[str, Any]:
is_context = meta.get("context") meta = getattr(fn_class, "_meta", {})
is_form = meta.get("form", False) camel = _snake_to_camel(fn_class.name)
has_input = _has_input(getattr(fn_class, "Input", None))
_, output_nullable = extract_optional(getattr(fn_class, "Output", None))
context = meta.get("context")
return { return {
"name": name, "name": fn_class.name,
"camel": camel, "camel": camel,
"has_input": has_input, "has_input": has_input,
"input_type": f"{camel}Input" if has_input else None, "input_type": f"{camel}Input" if has_input else None,
"output_type": output_type_name, "output_type": f"{camel}Output",
"output_nullable": output_nullable, "output_nullable": output_nullable,
"transport": "websocket" if meta.get("websocket") else "http", "transport": "websocket" if meta.get("websocket") else "http",
"context": is_context if isinstance(is_context, str) else None, "context": context if isinstance(context, str) else None,
"affects": [a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"], "affects": [
a["name"] for a in meta.get("affects") or [] if a.get("type") == "context"
],
"merge": list(meta.get("merge") or []), "merge": list(meta.get("merge") or []),
"is_form": bool(is_form), "is_form": bool(meta.get("form", False)),
"form_name": meta.get("form_name"), "form_name": meta.get("form_name"),
"form_role": meta.get("form_role"), "form_role": meta.get("form_role"),
} }
def _resolve_output(fn_class: Any) -> tuple[str, bool]:
"""Return `(output_type_name, output_nullable)` for an emitted function block."""
camel = _snake_to_camel(fn_class.name)
canonical = f"{camel}Output"
output_cls = getattr(fn_class, "Output", None)
if output_cls is None:
return canonical, False
_, nullable = extract_optional(output_cls)
return canonical, nullable
def _collect_channels() -> list[dict[str, Any]]:
"""Pull channel registrations from the optional `channels` registry extension."""
from mizan_core.registry import _extensions # type: ignore[attr-defined]
ext = _extensions.get("channels")
if ext is None:
return []
schema = ext.schema()
return list(schema or [])
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source."""
functions = get_all_functions()
context_groups = get_context_groups()
channels = _collect_channels()
named_types = _collect_named_types(functions)
lines: list[str] = []
root = _Block(lines, indent=0)
# ── Type definitions ──
for type_name in sorted(named_types):
shape = named_types[type_name]
with root.node("type", _kdl_string(type_name)) as type_block:
if isinstance(shape, _StructShape):
_emit_struct_type(type_block, shape.model, named_types)
elif isinstance(shape, _AliasShape):
_emit_alias_type(type_block, shape.annotation, named_types)
else:
raise TypeError(f"unknown named-type shape: {type(shape).__name__}")
if named_types:
lines.append("")
# ── Functions ──
# Alphabetical by wire name — the IR is a canonical contract, not a
# transcript of registration order. Both Python and Rust emitters sort
# so byte-equivalence holds across language-backed backends.
for fn_name in sorted(functions):
fn_class = functions[fn_name]
meta = getattr(fn_class, "_meta", {})
if meta.get("private") or meta.get("view_path"):
continue
output_type_name, output_nullable = _resolve_output(fn_class)
props = _function_props(fn_class, output_type_name, output_nullable)
_emit_function(root, props)
if functions:
lines.append("")
# ── Contexts ──
# Alphabetical by context name — same reason as functions above.
for ctx_name in sorted(context_groups):
_emit_context(root, ctx_name, context_groups[ctx_name])
if context_groups:
lines.append("")
# ── Channels ──
for channel in channels:
_emit_channel(root, channel)
# Trim trailing blanks then add a single terminating newline.
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines) + "\n"
def _emit_function(root: _Block, props: dict[str, Any]) -> None:
with root.node("function", _kdl_string(props["name"])) as block:
block.leaf("camel", _kdl_string(props["camel"]))
block.leaf("has-input", _kdl_bool(props["has_input"]))
if props["input_type"]:
block.leaf("input", _kdl_string(props["input_type"]))
block.leaf("output", _kdl_string(props["output_type"]))
if props["output_nullable"]:
block.leaf("output-nullable", _kdl_bool(True))
block.leaf("transport", _kdl_string(props["transport"]))
if props["context"]:
block.leaf("context", _kdl_string(props["context"]))
for affect_name in props["affects"]:
block.leaf("affects", _kdl_string(affect_name))
for merge_name in props["merge"]:
block.leaf("merge", _kdl_string(merge_name))
if props["is_form"]:
block.leaf("is-form", _kdl_bool(True))
if props["form_name"]:
block.leaf("form-name", _kdl_string(props["form_name"]))
if props["form_role"]:
block.leaf("form-role", _kdl_string(props["form_role"]))
def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
# First pass: collect param info across every function in the context.
param_info: dict[str, dict[str, Any]] = {}
for fn_name in fn_names:
fn_class = get_function(fn_name)
if fn_class is None:
continue
input_cls = getattr(fn_class, "Input", None)
if not _has_input(input_cls):
continue
for param_name, field_info in input_cls.model_fields.items():
slot = param_info.setdefault(param_name, {"type": None, "shared_by": []})
slot["type"] = _annotation_to_primitive(field_info.annotation)
slot["shared_by"].append(fn_name)
# A param is required iff every function in the context declares it.
for slot in param_info.values():
slot["required"] = len(slot["shared_by"]) == len(fn_names)
with root.node("context", _kdl_string(ctx_name)) as block:
# Members alphabetical — canonical order.
for fn_name in sorted(fn_names):
block.leaf("function", _kdl_string(fn_name))
for param_name in sorted(param_info):
slot = param_info[param_name]
with block.node("param", _kdl_string(param_name)) as param_block:
param_block.leaf("type", _kdl_string(slot["type"]))
param_block.leaf("required", _kdl_bool(slot["required"]))
# `shared-by` follows the same canonical ordering.
for sharer in sorted(slot["shared_by"]):
param_block.leaf("shared-by", _kdl_string(sharer))
def _annotation_to_primitive(annotation: Any) -> str: def _annotation_to_primitive(annotation: Any) -> str:
inner, _ = extract_optional(annotation) inner, _ = extract_optional(annotation)
if inner is int: if inner is int:
@@ -570,13 +370,95 @@ def _annotation_to_primitive(annotation: Any) -> str:
return "string" return "string"
def _emit_channel(root: _Block, channel: dict[str, Any]) -> None: def _context_entry(ctx_name: str, fn_names: list[str]) -> dict[str, Any]:
name = channel["name"] param_info: dict[str, dict[str, Any]] = {}
with root.node("channel", _kdl_string(name)) as block: for fn_name in fn_names:
block.leaf("pascal-name", _kdl_string(channel["pascalName"])) input_cls = getattr(get_function(fn_name), "Input", None)
if channel.get("hasParams") and channel.get("paramsType"): if not _has_input(input_cls):
block.leaf("params", _kdl_string(channel["paramsType"])) continue
if channel.get("hasReactMessage") and channel.get("reactMessageType"): for param_name, field_info in input_cls.model_fields.items():
block.leaf("react-message", _kdl_string(channel["reactMessageType"])) slot = param_info.setdefault(param_name, {"shared_by": []})
if channel.get("hasDjangoMessage") and channel.get("djangoMessageType"): slot["type"] = _annotation_to_primitive(field_info.annotation)
block.leaf("django-message", _kdl_string(channel["djangoMessageType"])) slot["shared_by"].append(fn_name)
return {
"name": ctx_name,
"functions": sorted(fn_names),
"params": [
{
"name": param_name,
"type": param_info[param_name]["type"],
# A param is required iff every function in the context takes it.
"required": len(param_info[param_name]["shared_by"]) == len(fn_names),
"shared_by": sorted(param_info[param_name]["shared_by"]),
}
for param_name in sorted(param_info)
],
}
_CHANNEL_SLOTS = (
("Params", "params"),
("ClientMessage", "client_message"),
("ServerMessage", "server_message"),
)
def _collect_channels() -> tuple[
list[dict[str, Any]], list[tuple[str, type[BaseModel]]]
]:
"""Channel blocks in wire-name order, plus the (emitted type name, model)
pair each declared slot resolves to."""
channel_classes = get_registry().get("channels", {})
records: list[dict[str, Any]] = []
models: list[tuple[str, type[BaseModel]]] = []
for wire_name in sorted(channel_classes):
channel_class = channel_classes[wire_name]
pascal = wire_to_pascal(wire_name)
record: dict[str, Any] = {"name": wire_name, "pascal_name": pascal}
for attribute, slot in _CHANNEL_SLOTS:
declared = getattr(channel_class, attribute, None)
if declared is None:
record[slot] = None
continue
if not (isinstance(declared, type) and issubclass(declared, BaseModel)):
raise TypeError(
f"channel '{wire_name}' declares {attribute} as {declared!r}, "
f"which is not a pydantic BaseModel subclass"
)
type_name = f"{pascal}{attribute}"
record[slot] = type_name
models.append((type_name, declared))
records.append(record)
return records, models
# ─── Top-level builder ──────────────────────────────────────────────────────
def build_ir() -> str:
"""Build the Mizan IR for every registered function. Returns KDL source.
An empty registry renders the empty document — zero bytes, zero nodes.
"""
functions = get_all_functions()
context_groups = get_context_groups()
channels, channel_models = _collect_channels()
# Every section is sorted by name, so the document does not depend on the
# order things were registered in.
return _ENV.get_template("ir/document.kdl.j2").render(
types=_resolve_named_types(_seed_named_types(functions, channel_models)),
functions=[
_function_entry(functions[name])
for name in sorted(functions)
if _is_emitted(functions[name])
],
contexts=[
_context_entry(name, context_groups[name])
for name in sorted(context_groups)
],
channels=channels,
)

View File

@@ -1,17 +1,12 @@
""" """
Mizan core registry — function and composition registration with an Mizan core registry — function and composition registration, plus an
extension hook for backend-specific registries (channels, forms, etc.) extension hook backend-specific registries (channels, forms, …) plug into.
to plug into.
This is the framework-agnostic registry. Backends own their own
type-specific registries (channels in Django Channels, forms in Django
Forms, websockets in FastAPI, etc.) and register them as extensions
here so the unified schema export can include them.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, Protocol import abc
from typing import Any, Callable
# ─── Core registries ──────────────────────────────────────────────────────── # ─── Core registries ────────────────────────────────────────────────────────
@@ -22,17 +17,23 @@ _compositions: dict[str, Any] = {}
# ─── Extension hook ───────────────────────────────────────────────────────── # ─── Extension hook ─────────────────────────────────────────────────────────
class RegistryExtension(Protocol): class RegistryExtension(abc.ABC):
""" """
Backend-specific registries plug into core via this Protocol. A backend registry of its own registrations (channels, forms, websocket
consumers, …) contributing one subdict to the unified schema export.
Each extension owns its own registry of backend-shaped registrations
(channels, forms, websocket consumers, etc.) and contributes a schema
subdict to the unified schema export.
""" """
def schema(self) -> dict[str, Any]: ... @abc.abstractmethod
def clear(self) -> None: ... def all(self) -> dict[str, Any]:
"""The live registry: registered name → registered class."""
@abc.abstractmethod
def schema(self) -> dict[str, Any]:
"""The schema subdict exported under this extension's name."""
@abc.abstractmethod
def clear(self) -> None:
"""Drop every registration held by this extension."""
_extensions: dict[str, RegistryExtension] = {} _extensions: dict[str, RegistryExtension] = {}
@@ -146,9 +147,6 @@ def get_registry() -> dict[str, Any]:
"compositions": _compositions.copy(), "compositions": _compositions.copy(),
} }
for name, ext in _extensions.items(): for name, ext in _extensions.items():
# Extensions optionally expose their backing dict via .all()
# (Protocol doesn't require it; only schema() and clear() are mandatory)
if hasattr(ext, "all"):
out[name] = ext.all() out[name] = ext.all()
return out return out

View File

@@ -0,0 +1,121 @@
{% macro literal(lit) %}
{%- if lit.kind == "string" -%}
{{ lit.value | kdl }}
{%- elif lit.kind == "bool" -%}
{{ lit.value | kdlbool }}
{%- else -%}
{{ lit.value }}
{%- endif %}
{%- endmacro %}
{% macro shape(node, depth) %}
{%- set pad = " " * depth %}
{%- if node.kind == "primitive" %}
{{ pad }}primitive {{ node.name | kdl }}
{%- elif node.kind == "ref" %}
{{ pad }}ref {{ node.name | kdl }}
{%- elif node.kind == "enum" %}
{{ pad }}enum {{ node["values"] | map("kdl") | join(" ") }}
{%- elif node.kind == "list" %}
{{ pad }}list {
{{ shape(node.of, depth + 1) }}
{{ pad }}}
{%- elif node.kind == "optional" %}
{{ pad }}optional {
{{ shape(node.of, depth + 1) }}
{{ pad }}}
{%- elif node.kind == "union" %}
{{ pad }}union {
{% for branch in node.branches %}
{{ shape(branch, depth + 1) }}
{% endfor %}
{{ pad }}}
{%- endif %}
{%- endmacro %}
{% for type in types %}
type {{ type.name | kdl }} {
{% if type.kind == "struct" %}
struct {
{% for field in type.fields %}
field {{ field.name | kdl }}{% if not field.required %} required={{ field.required | kdlbool }}{% endif %}{% if field.default %} default={{ literal(field.default) }}{% endif %} {
{{ shape(field.shape, 3) }}
}
{% endfor %}
}
{% else %}
alias {
{{ shape(type.shape, 2) }}
}
{% endif %}
}
{% endfor %}
{% if types and (functions or contexts or channels) %}
{% endif %}
{% for fn in functions %}
function {{ fn.name | kdl }} {
camel {{ fn.camel | kdl }}
has-input {{ fn.has_input | kdlbool }}
{% if fn.input_type %}
input {{ fn.input_type | kdl }}
{% endif %}
output {{ fn.output_type | kdl }}
{% if fn.output_nullable %}
output-nullable {{ fn.output_nullable | kdlbool }}
{% endif %}
transport {{ fn.transport | kdl }}
{% if fn.context %}
context {{ fn.context | kdl }}
{% endif %}
{% for affected in fn.affects %}
affects {{ affected | kdl }}
{% endfor %}
{% for merged in fn.merge %}
merge {{ merged | kdl }}
{% endfor %}
{% if fn.is_form %}
is-form {{ fn.is_form | kdlbool }}
{% if fn.form_name %}
form-name {{ fn.form_name | kdl }}
{% endif %}
{% if fn.form_role %}
form-role {{ fn.form_role | kdl }}
{% endif %}
{% endif %}
}
{% endfor %}
{% if functions and (contexts or channels) %}
{% endif %}
{% for context in contexts %}
context {{ context.name | kdl }} {
{% for fn_name in context.functions %}
function {{ fn_name | kdl }}
{% endfor %}
{% for param in context.params %}
param {{ param.name | kdl }} {
type {{ param.type | kdl }}
required {{ param.required | kdlbool }}
{% for sharer in param.shared_by %}
shared-by {{ sharer | kdl }}
{% endfor %}
}
{% endfor %}
}
{% endfor %}
{% if contexts and channels %}
{% endif %}
{% for channel in channels %}
channel {{ channel.name | kdl }} {
pascal-name {{ channel.pascal_name | kdl }}
{% if channel.params %}
params {{ channel.params | kdl }}
{% endif %}
{% if channel.client_message %}
client-message {{ channel.client_message | kdl }}
{% endif %}
{% if channel.server_message %}
server-message {{ channel.server_message | kdl }}
{% endif %}
}
{% endfor %}

View File

@@ -1,10 +1,4 @@
""" """Annotation-introspection helpers used when walking @client function signatures."""
Type-introspection helpers shared across backend adapters.
Both mizan-django and mizan-fastapi need to walk @client-decorated function
annotations the same way during schema export. Drift here breaks AFI parity,
so the helpers live in core.
"""
from __future__ import annotations from __future__ import annotations
@@ -28,9 +22,8 @@ def extract_optional(annotation: Any) -> tuple[Any, bool]:
Returns `(T, True)` for a union containing exactly one non-None member Returns `(T, True)` for a union containing exactly one non-None member
and `None` itself. For anything else, returns `(annotation, False)`. and `None` itself. For anything else, returns `(annotation, False)`.
Multi-arm unions like `A | B | None` are returned as-is — protocol-level A multi-arm union like `A | B | None` is returned as-is — picking one arm
discriminated unions aren't supported yet, and silently picking one arm would silently discard the others.
would hide that.
""" """
origin = get_origin(annotation) origin = get_origin(annotation)
if origin is Union or isinstance(annotation, types.UnionType): if origin is Union or isinstance(annotation, types.UnionType):
@@ -80,10 +73,9 @@ def is_structured_output(annotation: Any) -> bool:
def types_match_for_merge(slot_type: Any, value_type: Any) -> bool: def types_match_for_merge(slot_type: Any, value_type: Any) -> bool:
"""True if a `value_type` mutation return can splice into a `slot_type` context slot. """True if a `value_type` mutation return can splice into a `slot_type` slot.
Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete Three shapes match:
function-name slot inside the context bundle. Three shapes match:
- direct: slot is `T`, value is `T` → replace - direct: slot is `T`, value is `T` → replace
- upsert: slot is `list[T]`, value is `T` → upsert by id - upsert: slot is `list[T]`, value is `T` → upsert by id

View File

@@ -0,0 +1,107 @@
"""Unit tests for KDL IR emission. Every assertion runs on a real KDL parse tree."""
from unittest import TestCase
import ckdl
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.ir import build_ir
from mizan_core.registry import clear_registry, register
class Prefs(BaseModel):
live: bool = True
class Settings(BaseModel):
meta: Prefs = Prefs()
label: str = "plain"
quoted: str = 'a "b" \\ c\nd\te'
retries: int = 3
ratio: float = 0.5
enabled: bool = False
tags: list[str] = []
mapping: dict[str, str] = {}
note: str | None = None
who: str
def _struct_fields(document: ckdl.Document, type_name: str) -> dict[str, ckdl.Node]:
"""Field nodes of the named struct, keyed by field name."""
for node in document.nodes:
if node.name == "type" and node.args[0] == type_name:
for child in node.children:
if child.name == "struct":
return {field.args[0]: field for field in child.children}
raise AssertionError(f"no struct type {type_name!r} in:\n{document}")
class EmptyDocumentTests(TestCase):
"""The IR of an empty registry."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_empty_registry_emits_zero_bytes(self):
"""Nothing registered renders the empty document, not a blank line."""
self.assertEqual(build_ir(), "")
def test_empty_document_parses_to_zero_nodes(self):
"""The empty document is valid KDL carrying no nodes."""
self.assertEqual(len(ckdl.parse(build_ir()).nodes), 0)
class FieldDefaultTests(TestCase):
"""Which Pydantic field defaults reach the document as KDL literals."""
def setUp(self):
clear_registry()
@client
def get_settings(request) -> Settings:
return Settings(who="anyone")
register(get_settings, "get_settings")
self.fields = _struct_fields(
ckdl.parse(build_ir()), "getSettingsOutput"
)
def tearDown(self):
clear_registry()
def test_scalar_defaults_survive_the_round_trip(self):
"""bool, int, float and str defaults parse back to the Python values."""
self.assertEqual(self.fields["label"].properties["default"], "plain")
self.assertEqual(self.fields["retries"].properties["default"], 3)
self.assertEqual(self.fields["ratio"].properties["default"], 0.5)
self.assertEqual(self.fields["enabled"].properties["default"], False)
def test_string_default_escapes_round_trip(self):
"""Quotes, backslashes and control characters survive KDL escaping."""
self.assertEqual(
self.fields["quoted"].properties["default"], Settings.model_fields["quoted"].default
)
def test_model_valued_default_carries_no_literal(self):
"""A nested-model default has no KDL scalar form, so no `default` is emitted."""
meta = self.fields["meta"]
self.assertNotIn("default", meta.properties)
self.assertIs(meta.properties["required"], False)
def test_container_defaults_carry_no_literal(self):
"""List and dict defaults have no KDL scalar form either."""
self.assertNotIn("default", self.fields["tags"].properties)
self.assertNotIn("default", self.fields["mapping"].properties)
def test_none_default_carries_no_literal(self):
"""`= None` leaves the optional shape to say the field may be absent."""
self.assertNotIn("default", self.fields["note"].properties)
def test_required_field_has_no_required_property(self):
"""A required field is the default, so the property is left off entirely."""
self.assertNotIn("required", self.fields["who"].properties)
self.assertNotIn("default", self.fields["who"].properties)

View File

@@ -0,0 +1,111 @@
//! `#[mizan::channel("<wire-name>", params = T, client_message = T,
//! server_message = T)]` — emit the linkme `ChannelEntry` registration for a
//! unit struct. Every slot is optional; only the declared ones register, and
//! each slot type must implement `MizanType` (via `#[derive(Mizan)]`).
use heck::ToShoutySnakeCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::{Parse, ParseStream},
ItemStruct, LitStr, Path, Token,
};
mod kw {
syn::custom_keyword!(params);
syn::custom_keyword!(client_message);
syn::custom_keyword!(server_message);
}
/// Attribute args: the wire name, then the slot types the channel declares.
pub struct ChannelArgs {
pub wire_name: String,
pub params: Option<Path>,
pub client_message: Option<Path>,
pub server_message: Option<Path>,
}
impl Parse for ChannelArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: LitStr = input.parse()?;
let mut out = Self {
wire_name: name.value(),
params: None,
client_message: None,
server_message: None,
};
while input.peek(Token![,]) {
input.parse::<Token![,]>()?;
if input.is_empty() {
break;
}
if input.peek(kw::params) {
input.parse::<kw::params>()?;
input.parse::<Token![=]>()?;
out.params = Some(input.parse()?);
} else if input.peek(kw::client_message) {
input.parse::<kw::client_message>()?;
input.parse::<Token![=]>()?;
out.client_message = Some(input.parse()?);
} else if input.peek(kw::server_message) {
input.parse::<kw::server_message>()?;
input.parse::<Token![=]>()?;
out.server_message = Some(input.parse()?);
} else {
return Err(input.error(
"expected a channel slot: params, client_message, or server_message",
));
}
}
Ok(out)
}
}
pub fn expand(args: ChannelArgs, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() {
return syn::Error::new_spanned(
&item.fields,
"#[mizan::channel] requires a unit struct — the payload types are declared in the attribute.",
)
.to_compile_error();
}
let ident = item.ident.clone();
let wire_name = args.wire_name;
// Slots register in the order the IR emits them: params, client-message,
// server-message.
let mut slot_exprs: Vec<TokenStream> = Vec::new();
for (kind, declared) in [
(format_ident!("Params"), args.params),
(format_ident!("ClientMessage"), args.client_message),
(format_ident!("ServerMessage"), args.server_message),
] {
if let Some(ty) = declared {
slot_exprs.push(quote! {
::mizan_core::ChannelSlot {
kind: ::mizan_core::ChannelSlotKind::#kind,
shape_fn: <#ty as ::mizan_core::MizanType>::shape,
}
});
}
}
let register_static = format_ident!(
"__MIZAN_CHANNEL_REGISTER_{}",
ident.to_string().to_shouty_snake_case()
);
quote! {
#item
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CHANNELS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: ::mizan_core::ChannelEntry = ::mizan_core::ChannelEntry {
name: #wire_name,
slots: &[
#(#slot_exprs),*
],
};
}
}

View File

@@ -6,33 +6,33 @@ use proc_macro2::TokenStream;
use quote::{format_ident, quote}; use quote::{format_ident, quote};
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token}; use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
/// Attribute args: either nothing, or one string literal that overrides the /// Where the context's wire name comes from: the attribute, or the struct's
/// derived snake_case context name. /// own identifier when the attribute names none.
pub struct ContextArgs { pub enum ContextName {
pub explicit_name: Option<String>, Explicit(String),
FromIdent,
} }
impl ContextArgs { impl ContextName {
/// Both `#[mizan::context("user")]` (bare string literal) and
/// `#[mizan::context(name = "user")]` name the context explicitly.
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> { pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() { if attr_tokens.is_empty() {
return Ok(Self { explicit_name: None }); return Ok(ContextName::FromIdent);
} }
// Support both `#[mizan::context("user")]` (string literal) and
// `#[mizan::context(name = "user")]` (key=value).
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) { if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
return Ok(Self { return Ok(ContextName::Explicit(lit.value()));
explicit_name: Some(lit.value()),
});
} }
let parser = Punctuated::<Meta, Token![,]>::parse_terminated; let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?; let metas = parser.parse2(attr_tokens)?;
for meta in metas { for meta in metas {
if let Meta::NameValue(nv) = meta { if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("name") { if nv.path.is_ident("name") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { if let syn::Expr::Lit(syn::ExprLit {
return Ok(Self { lit: Lit::Str(s), ..
explicit_name: Some(s.value()), }) = nv.value
}); {
return Ok(ContextName::Explicit(s.value()));
} }
} }
} }
@@ -42,9 +42,16 @@ impl ContextArgs {
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`", "expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
)) ))
} }
fn resolve(self, ident: &syn::Ident) -> String {
match self {
ContextName::Explicit(name) => name,
ContextName::FromIdent => ident.to_string().to_snake_case(),
}
}
} }
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream { pub fn expand(name: ContextName, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() { if !item.fields.is_empty() {
return syn::Error::new_spanned( return syn::Error::new_spanned(
&item.fields, &item.fields,
@@ -54,9 +61,7 @@ pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
} }
let ident = item.ident.clone(); let ident = item.ident.clone();
let name = args let name = name.resolve(&ident);
.explicit_name
.unwrap_or_else(|| ident.to_string().to_snake_case());
let register_static = let register_static =
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase()); format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());

View File

@@ -1,18 +1,20 @@
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration. //! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase}; use heck::{ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
use proc_macro2::TokenStream; use proc_macro2::{TokenStream, TokenTree};
use quote::quote; use quote::{format_ident, quote};
use syn::{ use syn::{
parse::Parser, punctuated::Punctuated, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit, parse::{Parse, ParseStream},
Meta, Token, Data, DeriveInput, Field, Fields, FieldsNamed, Ident, Lit, Meta, Type,
}; };
use crate::shape::type_shape_expr; use crate::shape::{is_optional, type_shape_expr};
/// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust /// Apply a `#[serde(rename_all = "...")]` casing transform to a Rust
/// variant identifier so the IR's enum variant matches what serde emits /// variant identifier so the IR's enum variant matches what serde emits on
/// on the wire. Supported casings mirror serde's set. /// the wire. Supported casings mirror serde's set; any other rule — including
/// the empty rule an undecorated enum carries — leaves the identifier as
/// written.
fn apply_rename_all(rule: &str, ident: &str) -> String { fn apply_rename_all(rule: &str, ident: &str) -> String {
match rule { match rule {
"lowercase" => ident.to_lowercase(), "lowercase" => ident.to_lowercase(),
@@ -26,87 +28,210 @@ fn apply_rename_all(rule: &str, ident: &str) -> String {
} }
} }
/// Walk the enum's outer attributes for `#[serde(rename_all = "...")]`. /// The string a `#[serde(<key> = "...")]` entry in `attrs` carries, or
fn serde_rename_all(attrs: &[syn::Attribute]) -> Option<String> { /// `fallback` when no entry names `key`. serde owns that attribute's grammar
/// and its own derive reports a malformed body, so a body without the
/// `<key> = <string>` triple reads here as "no override".
fn serde_string(attrs: &[syn::Attribute], key: &str, fallback: String) -> String {
for attr in attrs { for attr in attrs {
if !attr.path().is_ident("serde") { if !attr.path().is_ident("serde") {
continue; continue;
} }
let list = match &attr.meta { let Meta::List(list) = &attr.meta else {
Meta::List(l) => l,
_ => continue,
};
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = match parser.parse2(list.tokens.clone()) {
Ok(m) => m,
Err(_) => continue,
};
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("rename_all") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Some(s.value());
}
}
}
}
}
None
}
/// Walk a variant's attributes for an explicit `#[serde(rename = "...")]`
/// override. Variant-level rename overrides the enum-level rename_all.
fn serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue; continue;
}
let list = match &attr.meta {
Meta::List(l) => l,
_ => continue,
}; };
let parser = Punctuated::<Meta, Token![,]>::parse_terminated; let mut on_key = false;
let metas = match parser.parse2(list.tokens.clone()) { let mut on_value = false;
Ok(m) => m, for tree in list.tokens.clone() {
Err(_) => continue, match tree {
}; TokenTree::Ident(ident) => {
for meta in metas { on_key = ident == key;
if let Meta::NameValue(nv) = meta { on_value = false;
if nv.path.is_ident("rename") { }
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { TokenTree::Punct(punct) => {
return Some(s.value()); on_value = on_key && punct.as_char() == '=';
}
TokenTree::Literal(literal) => {
if on_value {
if let Lit::Str(s) = Lit::new(literal) {
return s.value();
}
}
on_key = false;
on_value = false;
}
TokenTree::Group(_) => {
on_key = false;
on_value = false;
} }
} }
} }
} }
} fallback
None
} }
/// Expand `#[derive(Mizan)]`. Emits the `MizanType` impl AND a linkme /// A braced struct field paired with the identifier it carries. `all` is the
/// TypeEntry registration. Every Mizan-shaped type lands in the IR; /// only constructor and it reads a `FieldsNamed` group, so `ident` is a total
/// the emitter's inline-substitution pass collapses primitive-aliases /// accessor rather than an Option the caller has to open.
/// and enums at use sites so the IR stays tight. struct NamedField<'a> {
pub fn expand(input: DeriveInput) -> TokenStream { ident: &'a Ident,
let ident = input.ident.clone(); field: &'a Field,
let type_name = ident.to_string(); }
let rename_all = serde_rename_all(&input.attrs); impl<'a> NamedField<'a> {
fn all(braced: &'a FieldsNamed) -> impl Iterator<Item = Self> {
braced
.named
.iter()
.flat_map(|field| field.ident.as_ref().map(|ident| Self { ident, field }))
}
let named_type_body = match &input.data { /// The wire name serde emits: a `#[serde(rename)]` override, else the
Data::Struct(s) => emit_struct(s), /// identifier with serde's `r#` raw-prefix stripping applied.
Data::Enum(e) => emit_enum(e, rename_all.as_deref()), fn wire_name(&self) -> String {
let raw_ident = self.ident.to_string();
let default = raw_ident.trim_start_matches("r#").to_string();
serde_string(&self.field.attrs, "rename", default)
}
}
/// One struct field reduced to what the IR carries: the name serde puts on the
/// wire and the declared Rust type.
struct FieldShape {
wire_name: String,
ty: Type,
}
/// The two type forms the IR can express.
enum DerivedShape {
Struct(Vec<FieldShape>),
Enum(Vec<String>),
}
/// A derive input already reduced to the IR form its body takes. The token
/// stream is parsed straight into this shape, so `expand` reads a settled
/// name and body and has nothing left to reject.
pub struct MizanDerive {
ident: Ident,
shape: DerivedShape,
}
impl Parse for MizanDerive {
fn parse(input: ParseStream) -> syn::Result<Self> {
let input: DeriveInput = input.parse()?;
let shape = match &input.data {
Data::Struct(s) => {
let braced = match &s.fields {
Fields::Named(named) => named,
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs aren't part of the IR shape.",
));
}
Fields::Unit => {
return Err(syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Unit structs aren't part of the IR shape.",
));
}
};
let mut fields = Vec::new();
for named in NamedField::all(braced) {
fields.push(FieldShape {
wire_name: named.wire_name(),
ty: named.field.ty.clone(),
});
}
DerivedShape::Struct(fields)
}
Data::Enum(e) => {
let rename_all = serde_string(&input.attrs, "rename_all", String::new());
let mut variants = Vec::new();
for variant in &e.variants {
match &variant.fields {
Fields::Unit => {}
Fields::Named(_) => {
return Err(syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Struct variants aren't expressible in the current IR.",
));
}
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Tuple variants aren't expressible in the current IR.",
));
}
}
// Variant-level `rename` wins over the enum-level
// `rename_all` rule.
let default = apply_rename_all(&rename_all, &variant.ident.to_string());
variants.push(serde_string(&variant.attrs, "rename", default));
}
DerivedShape::Enum(variants)
}
Data::Union(_) => { Data::Union(_) => {
return syn::Error::new_spanned( return Err(syn::Error::new_spanned(
&input, &input,
"#[derive(Mizan)] does not support `union` types — use a struct or enum.", "#[derive(Mizan)] does not support `union` types — use a struct or enum.",
) ));
.to_compile_error();
} }
}; };
Ok(Self {
ident: input.ident,
shape,
})
}
}
let register_static = /// Build the `NamedType` expression the generated `shape()` returns.
quote::format_ident!("__MIZAN_TYPE_REGISTER_{}", ident.to_string().to_shouty_snake_case()); fn named_type_expr(shape: &DerivedShape) -> TokenStream {
match shape {
DerivedShape::Struct(fields) => {
let field_exprs: Vec<TokenStream> = fields
.iter()
.map(|field| {
let name = &field.wire_name;
// A Rust struct-field declaration carries no default
// expression, so `default` is always None and `required`
// follows the Option wrapper.
let required = !is_optional(&field.ty);
let shape = type_shape_expr(&field.ty);
quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
}
})
.collect();
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
DerivedShape::Enum(variants) => quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
},
}
}
/// Expand `#[derive(Mizan)]` — the `MizanType` impl plus the linkme
/// `TypeEntry` registration for the derived type.
pub fn expand(derived: MizanDerive) -> TokenStream {
let MizanDerive { ident, shape } = derived;
let named_type_body = named_type_expr(&shape);
let type_name = ident.to_string();
let register_static = format_ident!(
"__MIZAN_TYPE_REGISTER_{}",
type_name.to_shouty_snake_case()
);
quote! { quote! {
impl ::mizan_core::MizanType for #ident { impl ::mizan_core::MizanType for #ident {
@@ -123,84 +248,3 @@ pub fn expand(input: DeriveInput) -> TokenStream {
}; };
} }
} }
fn emit_struct(s: &DataStruct) -> TokenStream {
let fields = match &s.fields {
Fields::Named(named) => &named.named,
Fields::Unnamed(_) | Fields::Unit => {
return syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
)
.to_compile_error();
}
};
let mut field_exprs: Vec<TokenStream> = Vec::new();
for field in fields {
let ident = field
.ident
.as_ref()
.expect("named field always has an ident");
// Field-level `#[serde(rename = "...")]` wins; otherwise strip
// the raw-identifier prefix that Rust uses to escape keywords
// (`r#type` → `type`). Serde itself strips the prefix when
// computing the default field name; the IR has to match the
// wire form, not the Rust source form.
let raw_ident = ident.to_string();
let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident);
let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string());
let shape = type_shape_expr(&field.ty);
// A field is `required` iff its type is not `Option<...>`. Defaults
// are not encodable from Rust syntax (no `= expr` on a struct field
// declaration) — the macro emits `required: false, default: None`
// for Option-wrapped fields, leaving defaults for a future
// attribute-based extension.
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
let required = !is_optional;
field_exprs.push(quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
fn emit_enum(e: &DataEnum, rename_all: Option<&str>) -> TokenStream {
let mut variants: Vec<TokenStream> = Vec::new();
for variant in &e.variants {
if !matches!(variant.fields, Fields::Unit) {
return syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
)
.to_compile_error();
}
let raw = variant.ident.to_string();
// Variant-level `#[serde(rename = "...")]` wins; otherwise apply
// the enum-level `#[serde(rename_all = "...")]` rule.
let name = if let Some(explicit) = serde_rename(&variant.attrs) {
explicit
} else if let Some(rule) = rename_all {
apply_rename_all(rule, &raw)
} else {
raw
};
variants.push(quote! { #name });
}
quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
}
}

View File

@@ -2,7 +2,7 @@
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params //! * a synthetic Input struct (`<camelName>Input`) when the fn has params
//! * `MizanType` impl on the Input struct //! * `MizanType` impl on the Input struct
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`) //! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too) //! * list-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>` //! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE` //! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
@@ -10,13 +10,25 @@ use heck::{ToLowerCamelCase, ToShoutySnakeCase};
use proc_macro2::TokenStream; use proc_macro2::TokenStream;
use quote::{format_ident, quote}; use quote::{format_ident, quote};
use syn::{ use syn::{
parse::Parser, parenthesized,
parse::{Parse, ParseStream},
punctuated::Punctuated, punctuated::Punctuated,
spanned::Spanned, token::Paren,
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type, FnArg, Ident, ItemFn, Pat, Path, ReturnType, Token, Type,
}; };
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option}; use crate::shape::{
analyze_return, classify, is_optional, path_head, ref_shape_expr, type_shape_expr, Head,
ReturnForm, TypeForm,
};
mod kw {
syn::custom_keyword!(context);
syn::custom_keyword!(affects);
syn::custom_keyword!(merge);
syn::custom_keyword!(websocket);
syn::custom_keyword!(private);
}
/// Parsed attribute args for `#[mizan(...)]`. /// Parsed attribute args for `#[mizan(...)]`.
#[derive(Default)] #[derive(Default)]
@@ -28,125 +40,149 @@ pub struct FunctionArgs {
pub private: bool, pub private: bool,
} }
impl FunctionArgs { impl Parse for FunctionArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> { fn parse(input: ParseStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self::default());
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
let mut out = Self::default(); let mut out = Self::default();
for meta in metas { while !input.is_empty() {
match meta { if input.peek(kw::context) {
Meta::NameValue(nv) => { input.parse::<kw::context>()?;
if nv.path.is_ident("context") { input.parse::<Token![=]>()?;
out.context = Some(expect_path(&nv.value)?); out.context = Some(input.parse()?);
} else if nv.path.is_ident("affects") { } else if input.peek(kw::affects) {
out.affects = collect_paths(&nv.value)?; input.parse::<kw::affects>()?;
} else if nv.path.is_ident("merge") { input.parse::<Token![=]>()?;
out.merge = collect_paths(&nv.value)?; out.affects = parse_path_group(input)?;
} else { } else if input.peek(kw::merge) {
return Err(syn::Error::new_spanned( input.parse::<kw::merge>()?;
nv.path, input.parse::<Token![=]>()?;
"unknown attribute key; expected one of: context, affects, merge", out.merge = parse_path_group(input)?;
)); } else if input.peek(kw::websocket) {
} input.parse::<kw::websocket>()?;
}
Meta::Path(p) => {
if p.is_ident("websocket") {
out.websocket = true; out.websocket = true;
} else if p.is_ident("private") { } else if input.peek(kw::private) {
input.parse::<kw::private>()?;
out.private = true; out.private = true;
} else { } else {
return Err(syn::Error::new_spanned( return Err(input.error(
p, "expected one of: `context = T`, `affects = T`, `merge = T`, `websocket`, `private`",
"unknown flag; expected `websocket` or `private`",
)); ));
} }
if input.is_empty() {
break;
} }
Meta::List(l) => { input.parse::<Token![,]>()?;
}
if let Some(ctx) = &out.context {
if !out.affects.is_empty() {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
l, ctx,
"list-shaped attribute args not supported here",
));
}
}
}
if out.context.is_some() && !out.affects.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.", "`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
)); ));
} }
if out.context.is_some() && !out.merge.is_empty() { if !out.merge.is_empty() {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(), ctx,
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.", "`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
)); ));
} }
}
Ok(out) Ok(out)
} }
} }
fn expect_path(expr: &Expr) -> syn::Result<Path> { /// One context type (`affects = Ctx`) or a parenthesized group of them
if let Expr::Path(ExprPath { path, .. }) = expr { /// (`affects = (CtxA, CtxB)`).
Ok(path.clone()) fn parse_path_group(input: ParseStream) -> syn::Result<Vec<Path>> {
if input.peek(Paren) {
let group;
parenthesized!(group in input);
Ok(Punctuated::<Path, Token![,]>::parse_terminated(&group)?
.into_iter()
.collect())
} else { } else {
Err(syn::Error::new_spanned( Ok(vec![input.parse()?])
expr,
"expected a type path (e.g. `UserCtx`)",
))
}
}
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
match expr {
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
_ => Err(syn::Error::new_spanned(
expr,
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
)),
} }
} }
/// Information about one input parameter, extracted from the fn signature. /// Information about one input parameter, extracted from the fn signature.
struct InputArg { struct InputArg {
ident: syn::Ident, ident: Ident,
ty: Type, ty: Type,
} }
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { /// The handler grammar `#[mizan::client]` accepts: an `async fn` taking a
if item.sig.asyncness.is_none() { /// request handle followed by plain-identifier params, with an explicit return
return syn::Error::new_spanned( /// type. The token stream is parsed straight into this shape, so `expand`
&item.sig.fn_token, /// reads three settled fields and has nothing left to reject.
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.", ///
) /// A missing `async` or a missing request handle needs no rejection here: the
.to_compile_error(); /// dispatch wrapper `expand` emits calls the handler with `&req` and awaits
/// the call, so rustc rejects both at the generated call site.
pub struct Handler {
item: ItemFn,
input_args: Vec<InputArg>,
return_ty: Type,
}
impl Parse for Handler {
fn parse(input: ParseStream) -> syn::Result<Self> {
let item: ItemFn = input.parse()?;
let ReturnType::Type(_, declared) = &item.sig.output else {
return Err(syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
));
};
let return_ty = (**declared).clone();
let mut input_args = Vec::new();
// The first arg is the request handle, which the dispatch wrapper
// forwards as `req`; it never becomes an Input field.
for arg in item.sig.inputs.iter().skip(1) {
let typed = match arg {
FnArg::Typed(typed) => typed,
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
} }
};
let Pat::Ident(bound) = &*typed.pat else {
return Err(syn::Error::new_spanned(
&typed.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
};
input_args.push(InputArg {
ident: bound.ident.clone(),
ty: (*typed.ty).clone(),
});
}
Ok(Self {
item,
input_args,
return_ty,
})
}
}
pub fn expand(args: FunctionArgs, handler: Handler) -> TokenStream {
let Handler {
item,
input_args,
return_ty,
} = handler;
let fn_name = item.sig.ident.to_string(); let fn_name = item.sig.ident.to_string();
let camel = fn_name.to_lower_camel_case(); let camel = fn_name.to_lower_camel_case();
let input_type_name = format!("{camel}Input"); let input_type_name = format!("{camel}Input");
let output_type_name = format!("{camel}Output"); let output_type_name = format!("{camel}Output");
let input_args = match collect_input_args(&item) {
Ok(v) => v,
Err(e) => return e.to_compile_error(),
};
let has_input = !input_args.is_empty(); let has_input = !input_args.is_empty();
let input_type_ident = format_ident!("{}", input_type_name); let input_type_ident = format_ident!("{}", input_type_name);
let return_ty = match &item.sig.output {
ReturnType::Type(_, t) => (**t).clone(),
ReturnType::Default => {
return syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
)
.to_compile_error();
}
};
let analysis = analyze_return(&return_ty); let analysis = analyze_return(&return_ty);
// ─── Synthetic Input struct ──────────────────────────────────────────── // ─── Synthetic Input struct ────────────────────────────────────────────
@@ -156,12 +192,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
for arg in &input_args { for arg in &input_args {
let ident = &arg.ident; let ident = &arg.ident;
let ty = &arg.ty; let ty = &arg.ty;
// Strip a leading underscore from the wire-level field name — // Rust convention writes `_foo` to silence an unused-arg warning,
// Rust convention uses `_foo` to silence unused-arg warnings, // but the wire schema names the param `foo`. The struct field
// but the wire schema and the Python fixture name the param // keeps its source ident so the dispatch wrapper's
// `foo`. The struct field keeps its source ident (so the // `validated.#ident` compiles, and a serde `rename` bridges the
// dispatch wrapper's `validated.#ident` compiles), and a serde // JSON name.
// `rename` bridges the wire-level JSON name.
let name_str = ident.to_string(); let name_str = ident.to_string();
let wire_name = name_str.trim_start_matches('_').to_string(); let wire_name = name_str.trim_start_matches('_').to_string();
let serde_rename = if wire_name != name_str { let serde_rename = if wire_name != name_str {
@@ -170,8 +205,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
TokenStream::new() TokenStream::new()
}; };
field_defs.push(quote! { #serde_rename pub #ident: #ty, }); field_defs.push(quote! { #serde_rename pub #ident: #ty, });
let is_optional = unwrap_option(ty).is_some(); let required = !is_optional(ty);
let required = !is_optional;
let shape = type_shape_expr(ty); let shape = type_shape_expr(ty);
field_shapes.push(quote! { field_shapes.push(quote! {
::mizan_core::StructField { ::mizan_core::StructField {
@@ -202,11 +236,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
}; };
// ─── Type entry registrations ────────────────────────────────────────── // ─── Type entry registrations ──────────────────────────────────────────
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
// - Output: TypeEntry whose shape is a copy of the user's Output shape
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
// MizanType impl (so the Ref resolves in the IR).
let mut type_registrations = Vec::new(); let mut type_registrations = Vec::new();
if has_input { if has_input {
let static_ident = let static_ident =
@@ -222,66 +251,67 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
} }
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case()); let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
if analysis.is_vec { let output_shape_expr = match &analysis.form {
let elem = analysis.vec_inner.as_ref().expect("vec_inner set"); ReturnForm::Sequence { element } => {
// userOrdersOutput → alias { list { ref "OrderOutput" } } let element_ref = ref_shape_expr(element);
// The Ref name is resolved via `<T as MizanType>::type_name()`. let alias = quote! {
::mizan_core::NamedType::Alias(
::mizan_core::TypeShape::List(::std::boxed::Box::new(#element_ref))
)
};
type_registrations.push(quote! { type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)] #[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name, name: #output_type_name,
shape_fn: || ::mizan_core::NamedType::Alias( shape_fn: || #alias,
::mizan_core::TypeShape::List(::std::boxed::Box::new(
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
))
),
}; };
}); });
// Also register the element type itself by its own name. `TYPE_NAME` // The element type also registers under its own name. The static
// is an associated const, so this is usable in a static initializer. // ident is scoped by the function name so two handlers returning
// The static ident scopes by the function name so two handlers // `Vec<Same>` don't collide; the emitter dedupes by entry name.
// returning `Vec<Same>` don't collide; the IrSnapshot's BTreeMap let element_static =
// dedupes by the entry's `name` at emit time. element_type_static_ident_scoped(element, &fn_name.to_shouty_snake_case());
let elem_static =
element_type_static_ident_scoped(elem, &fn_name.to_shouty_snake_case());
type_registrations.push(quote! { type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)] #[linkme(crate = ::mizan_core::__priv::linkme)]
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { static #element_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME, name: <#element as ::mizan_core::MizanType>::TYPE_NAME,
shape_fn: <#elem as ::mizan_core::MizanType>::shape, shape_fn: <#element as ::mizan_core::MizanType>::shape,
};
});
} else {
// Non-Vec output: copy the inner type's shape under the canonical name.
let inner_ty = &analysis.inner;
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
}; };
}); });
alias
} }
ReturnForm::Scalar { inner } => {
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner as ::mizan_core::MizanType>::shape,
};
});
quote! { <#inner as ::mizan_core::MizanType>::shape() }
}
};
// ─── InputParam slice (for context-builder shared-param elevation) ──── // ─── InputParam slice (for context-builder shared-param elevation) ────
// A non-primitive param is an opaque payload in the context's `param`
// block and carries the string primitive.
let opaque_primitive = || quote! { ::mizan_core::Primitive::String };
let mut input_params = Vec::new(); let mut input_params = Vec::new();
for arg in &input_args { for arg in &input_args {
// Wire-level name strips the underscore prefix — see input_struct // Wire-level name strips the underscore prefix — see input_struct
// above for the rationale. // above.
let name_str = arg.ident.to_string(); let name_str = arg.ident.to_string();
let name_str = name_str.trim_start_matches('_').to_string(); let name_str = name_str.trim_start_matches('_').to_string();
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| { let primitive = match classify(&arg.ty) {
// Non-primitive params don't surface in the context's `param` TypeForm::Primitive(p) => p,
// block; they participate as opaque payloads. Using `String` as TypeForm::Optional(_) => opaque_primitive(),
// the placeholder primitive matches Python's fallback in TypeForm::Sequence(_) => opaque_primitive(),
// `_annotation_to_primitive`. TypeForm::Named(_) => opaque_primitive(),
quote! { ::mizan_core::Primitive::String } };
}); let required = !is_optional(&arg.ty);
let is_optional = unwrap_option(&arg.ty).is_some();
let required = !is_optional;
input_params.push(quote! { input_params.push(quote! {
::mizan_core::InputParam { ::mizan_core::InputParam {
name: #name_str, name: #name_str,
@@ -354,7 +384,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
let private = args.private; let private = args.private;
let dispatch_body = build_dispatch( let dispatch_body = build_dispatch(
&item, &inner_fn_ident,
&input_args, &input_args,
has_input, has_input,
&input_type_ident, &input_type_ident,
@@ -362,8 +392,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
); );
quote! { quote! {
// Keep the user's original fn intact — the macro never rewrites the
// body, only wraps it for dispatch.
#item #item
#input_struct #input_struct
@@ -383,6 +411,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
fn has_input(&self) -> bool { #has_input } fn has_input(&self) -> bool { #has_input }
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt } fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
fn output_type(&self) -> &'static str { #output_type_name } fn output_type(&self) -> &'static str { #output_type_name }
fn output_shape(&self) -> ::mizan_core::NamedType { #output_shape_expr }
fn output_nullable(&self) -> bool { #output_nullable } fn output_nullable(&self) -> bool { #output_nullable }
fn context(&self) -> ::std::option::Option<&'static str> { #context_value } fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static } fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
@@ -416,57 +445,15 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
} }
} }
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
let mut out = Vec::new();
let mut iter = item.sig.inputs.iter();
// First arg is the request handle — skip without inspection. The function
// body uses it directly; the dispatch wrapper forwards `req`.
if iter.next().is_none() {
return Err(syn::Error::new(
item.sig.span(),
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
));
}
for arg in iter {
match arg {
FnArg::Typed(pat) => {
let ident = match &*pat.pat {
Pat::Ident(pi) => pi.ident.clone(),
_ => {
return Err(syn::Error::new_spanned(
&pat.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
}
};
out.push(InputArg {
ident,
ty: (*pat.ty).clone(),
});
}
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
}
}
}
Ok(out)
}
fn build_dispatch( fn build_dispatch(
item: &ItemFn, inner: &Ident,
input_args: &[InputArg], input_args: &[InputArg],
has_input: bool, has_input: bool,
input_type_ident: &syn::Ident, input_type_ident: &Ident,
returns_result: bool, returns_result: bool,
) -> TokenStream { ) -> TokenStream {
let inner = &item.sig.ident; // `?` lifts a user `Result<T, MizanError>`'s Err into the wrapper's outer
// When the user returns `Result<T, MizanError>`, lift Err out into the // Result; a plain `T` serializes directly.
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
// it as the standard error envelope. When the user returns `T`,
// serialize directly — the substrate has no error path for them.
let unwrap_user_result = if returns_result { let unwrap_user_result = if returns_result {
quote! { ? } quote! { ? }
} else { } else {
@@ -501,16 +488,17 @@ fn build_dispatch(
} }
} }
fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> syn::Ident { /// A static-name for the element type's registration entry, scoped by the
// Derive a unique static-name for the type's registration entry, /// surrounding function so siblings returning the same `Vec<T>` don't collide
// scoped by the surrounding function so siblings returning the same /// at the static-name layer.
// `Vec<T>` don't collide at the static-name layer. The IR-side fn element_type_static_ident_scoped(ty: &Type, fn_scope: &str) -> Ident {
// BTreeMap dedupes by TypeEntry.name at emission time. let stem = match path_head(ty) {
let last = match ty { Head::Path { name, .. } => name,
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()), Head::Unnamed => "ANON".to_string(),
_ => None,
}; };
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case(); format_ident!(
format_ident!("__MIZAN_TYPE_ELEM_{}_FOR_{}", suffix, fn_scope) "__MIZAN_TYPE_ELEM_{}_FOR_{}",
stem.to_shouty_snake_case(),
fn_scope
)
} }

View File

@@ -1,47 +1,34 @@
//! Proc macros for `mizan-core`. See sibling modules for each macro's body. //! Proc macros for `mizan-core`. See sibling modules for each macro's body:
//! `derive` for `#[derive(Mizan)]`, `context` / `function` / `channel` for the
//! three attribute macros, `shape` for the shared `syn::Type` lowering.
//! //!
//! Consumer code reads: //! The function macro is named `client` so `mizan::` stays purely a module
//! ```ignore //! path — a module path can't simultaneously be a callable macro in Rust, so
//! use mizan_core::prelude::*; //! `#[mizan(...)]` would collide with `mizan::context`.
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
//!
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
//!
//! #[mizan::context("user")]
//! pub struct UserCtx;
//!
//! #[mizan::client(context = UserCtx)]
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
//! ```
//!
//! The function macro is named `client` to mirror Python's `@client`
//! decorator and to keep the namespace `mizan::` purely a module path —
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
//! can't simultaneously be a callable macro in Rust).
mod channel;
mod context; mod context;
mod derive; mod derive;
mod function; mod function;
mod shape; mod shape;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct}; use syn::{parse_macro_input, ItemStruct};
#[proc_macro_derive(Mizan)] #[proc_macro_derive(Mizan)]
pub fn derive_mizan(input: TokenStream) -> TokenStream { pub fn derive_mizan(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput); let derived = parse_macro_input!(input as derive::MizanDerive);
derive::expand(input).into() derive::expand(derived).into()
} }
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match context::ContextArgs::parse(attr.into()) { let name = match context::ContextName::parse(attr.into()) {
Ok(a) => a, Ok(n) => n,
Err(e) => return e.to_compile_error().into(), Err(e) => return e.to_compile_error().into(),
}; };
let item = parse_macro_input!(item as ItemStruct); let item = parse_macro_input!(item as ItemStruct);
context::expand(args, item).into() context::expand(name, item).into()
} }
/// The function-registration attribute macro. Used as `#[mizan::client]` /// The function-registration attribute macro. Used as `#[mizan::client]`
@@ -49,10 +36,17 @@ pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
/// websocket, private)]`. /// websocket, private)]`.
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match function::FunctionArgs::parse(attr.into()) { let args = parse_macro_input!(attr as function::FunctionArgs);
Ok(a) => a, let handler = parse_macro_input!(item as function::Handler);
Err(e) => return e.to_compile_error().into(), function::expand(args, handler).into()
}; }
let item = parse_macro_input!(item as ItemFn);
function::expand(args, item).into() /// The channel-registration attribute macro. Used as
/// `#[mizan::channel("<wire-name>", params = P, client_message = C,
/// server_message = S)]` on a unit struct; every slot is optional.
#[proc_macro_attribute]
pub fn channel(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as channel::ChannelArgs);
let item = parse_macro_input!(item as ItemStruct);
channel::expand(args, item).into()
} }

View File

@@ -6,203 +6,189 @@ use proc_macro2::TokenStream;
use quote::quote; use quote::quote;
use syn::{GenericArgument, PathArguments, Type, TypePath}; use syn::{GenericArgument, PathArguments, Type, TypePath};
/// Result of inspecting a fn's return type. /// The IR-relevant form of a Rust type. Every `syn::Type` lands in exactly
/// one arm, so classification never reports "unknown".
pub enum TypeForm {
/// `Option<T>` — the wire field is nullable.
Optional(Type),
/// `Vec<T>`, `[T; N]`, or a map whose values are `T` — a JSON array.
Sequence(Type),
/// A scalar, carrying the `::mizan_core::Primitive` variant expression.
Primitive(TokenStream),
/// Anything else: a type expected to implement `MizanType`.
Named(Type),
}
/// What a type's head is, as the lowering reads it. `Unnamed` covers the
/// forms with no path to name — tuples, references, slices, bare fns — which
/// carry no keyword the callers below test for.
pub enum Head {
Path { name: String, generics: Vec<Type> },
Unnamed,
}
/// Which of the two output shapes a handler's return type produces.
pub enum ReturnForm {
/// The handler yields a list; the caller registers an alias type over
/// `element`'s Ref.
Sequence { element: Type },
/// The handler yields one value; the caller registers `inner`'s own shape
/// under the canonical output name.
Scalar { inner: Type },
}
pub struct ReturnAnalysis { pub struct ReturnAnalysis {
/// Inner type once `Option<...>` is unwrapped. pub form: ReturnForm,
pub inner: Type, /// True if the outermost wrapper (after `Result`) is `Option<...>`.
/// True if the outermost wrapper is `Option<...>`.
pub nullable: bool, pub nullable: bool,
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
pub is_vec: bool,
/// When `is_vec`, this is the element type `T`.
pub vec_inner: Option<Type>,
/// True when the user's return type is `Result<T, MizanError>` — the /// True when the user's return type is `Result<T, MizanError>` — the
/// dispatch wrapper emits `?` so user-side errors bubble out as /// dispatch wrapper emits `?` so user-side errors bubble out as
/// `MizanError` instead of being serialized into the success payload. /// `MizanError` instead of being serialized into the success payload.
/// The IR sees only the `T` side; the error variant is the substrate's
/// invariant, not part of the output shape.
pub returns_result: bool, pub returns_result: bool,
} }
pub fn analyze_return(ty: &Type) -> ReturnAnalysis { pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) { let (effective, returns_result) = strip_result(ty);
(ok, true) let (unwrapped, nullable) = match classify(&effective) {
} else { TypeForm::Optional(inner) => (inner, true),
TypeForm::Sequence(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => (effective, false),
};
let form = match classify(&unwrapped) {
TypeForm::Sequence(element) => ReturnForm::Sequence { element },
TypeForm::Optional(_) | TypeForm::Primitive(_) | TypeForm::Named(_) => {
ReturnForm::Scalar { inner: unwrapped }
}
};
ReturnAnalysis {
form,
nullable,
returns_result,
}
}
/// Peel `Result<T, E>` down to `T`. `E` is left to rustc: a non-`MizanError`
/// error type fails at the `?` site the dispatch wrapper emits.
pub fn strip_result(ty: &Type) -> (Type, bool) {
if let Head::Path { name, generics } = path_head(ty) {
if name == "Result" {
if let [ok, ..] = generics.as_slice() {
return (ok.clone(), true);
}
}
}
(ty.clone(), false) (ty.clone(), false)
};
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
(t, true)
} else {
(effective, false)
};
if let Some(elem) = unwrap_vec(&inner) {
ReturnAnalysis {
inner: inner.clone(),
nullable,
is_vec: true,
vec_inner: Some(elem),
returns_result,
}
} else {
ReturnAnalysis {
inner,
nullable,
is_vec: false,
vec_inner: None,
returns_result,
}
}
} }
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate pub fn classify(ty: &Type) -> TypeForm {
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify if let Type::Array(array) = ty {
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if return TypeForm::Sequence((*array.elem).clone());
/// the consumer used a non-MizanError variant.
pub fn unwrap_result_ok(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Result" {
return None;
} }
extract_single_generic(&last.arguments) let Head::Path { name, generics } = path_head(ty) else {
return TypeForm::Named(ty.clone());
};
let args = generics.as_slice();
if name == "Option" {
if let [inner, ..] = args {
return TypeForm::Optional(inner.clone());
}
}
if name == "Vec" {
if let [element, ..] = args {
return TypeForm::Sequence(element.clone());
}
}
if name == "BTreeMap" || name == "HashMap" {
// A string-keyed map lands on the wire as a JSON object; the IR
// carries only the value shape, as a list element.
if let [_key, value, ..] = args {
return TypeForm::Sequence(value.clone());
}
}
classify_scalar(ty, &name)
} }
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]` pub fn is_optional(ty: &Type) -> bool {
/// when constructing the struct field shapes. matches!(classify(ty), TypeForm::Optional(_))
}
/// Emit a `TypeShape` const-expression for `ty`. Used inside
/// `#[derive(Mizan)]` when constructing the struct field shapes.
pub fn type_shape_expr(ty: &Type) -> TokenStream { pub fn type_shape_expr(ty: &Type) -> TokenStream {
if let Some(inner) = unwrap_option(ty) { match classify(ty) {
TypeForm::Optional(inner) => {
let inner_shape = type_shape_expr(&inner); let inner_shape = type_shape_expr(&inner);
return quote! { quote! {
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape)) ::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
};
} }
if let Some(elem) = unwrap_vec(ty) { }
let inner_shape = type_shape_expr(&elem); TypeForm::Sequence(element) => {
return quote! { let inner_shape = type_shape_expr(&element);
quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
} }
if let Some(elem) = unwrap_array(ty) {
// `[T; N]` lowers to `list { T }` on the wire — JSON arrays don't
// carry length, so the IR contract is the same as `Vec<T>`.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
} }
if let Some(elem) = unwrap_btreemap_value(ty) { TypeForm::Primitive(primitive) => {
// `BTreeMap<K, V>` on the wire is a JSON object keyed by `K`'s quote! { ::mizan_core::TypeShape::Primitive(#primitive) }
// string form. The Mizan IR doesn't model dynamic-keyed maps as a
// distinct shape — closest equivalent is a list of value entries.
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
} }
if let Some(p) = primitive_of(ty) { TypeForm::Named(named) => ref_shape_expr(&named),
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
}
// Fallback: assume a user-defined struct/enum implementing MizanType.
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
}
/// If `ty` is `[T; N]`, return `T`. Otherwise None.
pub fn unwrap_array(ty: &Type) -> Option<Type> {
if let Type::Array(a) = ty {
Some((*a.elem).clone())
} else {
None
} }
} }
/// If `ty` is `BTreeMap<K, V>` or `HashMap<K, V>`, return `V` (the value). /// A `TypeShape::Ref` carrying both the referent's IR name and its shape
/// String-keyed maps land on the wire as JSON objects; the IR carries the /// constructor, so resolving the reference needs no registry lookup.
/// value shape as a list element since KDL doesn't model dynamic-keyed maps pub fn ref_shape_expr(ty: &Type) -> TokenStream {
/// distinctly yet. quote! {
pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> { ::mizan_core::TypeShape::Ref {
let path = match ty { name: <#ty as ::mizan_core::MizanType>::TYPE_NAME,
Type::Path(TypePath { qself: None, path }) => path, shape: <#ty as ::mizan_core::MizanType>::shape,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
if name != "BTreeMap" && name != "HashMap" {
return None;
} }
let args = match &last.arguments { }
}
const INTEGER_IDENTS: &[&str] = &[
"i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize",
];
fn classify_scalar(ty: &Type, name: &str) -> TypeForm {
if INTEGER_IDENTS.contains(&name) {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Integer });
}
if name == "f32" || name == "f64" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Number });
}
if name == "bool" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::Boolean });
}
if name == "String" || name == "str" {
return TypeForm::Primitive(quote! { ::mizan_core::Primitive::String });
}
TypeForm::Named(ty.clone())
}
/// The last path segment's identifier and its generic type arguments.
pub fn path_head(ty: &Type) -> Head {
if let Type::Path(TypePath { qself: None, path }) = ty {
if let Some(last) = path.segments.last() {
return Head::Path {
name: last.ident.to_string(),
generics: generic_types(&last.arguments),
};
}
}
Head::Unnamed
}
fn generic_types(args: &PathArguments) -> Vec<Type> {
let angled = match args {
PathArguments::AngleBracketed(a) => a, PathArguments::AngleBracketed(a) => a,
_ => return None, PathArguments::None => return Vec::new(),
PathArguments::Parenthesized(_) => return Vec::new(),
}; };
// BTreeMap<K, V> — second type argument is V. let mut out = Vec::new();
let mut type_args = args.args.iter().filter_map(|a| { for arg in &angled.args {
if let GenericArgument::Type(t) = a {
Some(t.clone())
} else {
None
}
});
type_args.next()?; // skip K
type_args.next()
}
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
/// known primitive scalar.
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
match name.as_str() {
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
_ => None,
}
}
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
pub fn unwrap_option(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Option" {
return None;
}
extract_single_generic(&last.arguments)
}
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Vec" {
return None;
}
extract_single_generic(&last.arguments)
}
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
let args = match args {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
for arg in &args.args {
if let GenericArgument::Type(t) = arg { if let GenericArgument::Type(t) = arg {
return Some(t.clone()); out.push(t.clone());
} }
} }
None out
} }

View File

@@ -1,20 +1,14 @@
//! Mizan SSR engine. //! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with
//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function
//! that bundle defines.
//! //!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time //! `deno_web` supplies the web-platform globals a bare isolate lacks —
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler //! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a //! implementations rather than partial shims.
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//! //!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`, //! Only the bundle is ever `eval`'d. Per-render props enter through
//! timers, `MessagePort`, `performance` — come from `deno_web` as real //! `v8::json::parse` and are handed in as a call argument, so a prop string has
//! web-platform implementations, not shims (a partial polyfill is //! no surrounding source to break out of.
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! per-render data crosses as a `v8::json::parse`d value passed as a function
//! argument, so a prop string has no source to break out of — code injection
//! is structurally absent, not filtered.
use std::sync::Arc; use std::sync::Arc;
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
globalThis.TextDecoder = te.TextDecoder; globalThis.TextDecoder = te.TextDecoder;
}"#; }"#;
/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent
/// or not callable. The script therefore either fails — arriving in Rust as
/// the evaluator's own error — or produces a callable, which is what lets the
/// engine take it as a `v8::Function` without a second check.
const TAKE_RENDER_APP: &str = r#"(() => {
const f = globalThis.renderApp;
if (typeof f !== "function") {
throw new TypeError("the SSR bundle assigns no callable `renderApp`");
}
return f;
})()"#;
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform /// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint /// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread). /// means an engine is not `Send`; hold one per worker thread).
///
/// `render_fn` is taken during construction, so a render calls a function this
/// engine already owns and repeats no lookup.
pub struct SsrEngine { pub struct SsrEngine {
runtime: JsRuntime, runtime: JsRuntime,
render_fn: v8::Global<v8::Function>,
} }
impl SsrEngine { impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`). /// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`),
/// and take hold of that function.
pub fn new(bundle: String) -> Result<Self> { pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions { let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![ extensions: vec![
@@ -64,50 +75,64 @@ impl SsrEngine {
runtime runtime
.execute_script("[mizan:bundle]", bundle) .execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?; .context("evaluating the SSR bundle")?;
Ok(Self { runtime }) let render_app = runtime
.execute_script("[mizan:render-app]", TAKE_RENDER_APP)
.context("taking `renderApp` from the evaluated bundle")?;
let render_fn = {
deno_core::scope!(scope, &mut runtime);
let func = v8::Local::new(scope, render_app).cast::<v8::Function>();
v8::Global::new(scope, func)
};
Ok(Self { runtime, render_fn })
} }
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json` /// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
/// is a JSON object string; it is parsed to a V8 value and passed as an /// is a JSON object string; it is parsed to a V8 value and passed as an
/// argument — never spliced into evaluated source. /// argument — never spliced into evaluated source.
pub fn render(&mut self, props_json: &str) -> Result<String> { pub fn render(&mut self, props_json: &str) -> Result<String> {
let render_fn = self.render_fn.clone();
deno_core::scope!(scope, &mut self.runtime); deno_core::scope!(scope, &mut self.runtime);
let context = scope.get_current_context(); let func = v8::Local::new(scope, &render_fn);
let global = context.global(scope);
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
let func_val = global
.get(scope, key.into())
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
let func: v8::Local<v8::Function> = func_val
.try_into()
.map_err(|_| anyhow!("renderApp is not a function"))?;
let props_str = v8::String::new(scope, props_json).context("intern props")?;
let props = v8::json::parse(scope, props_str)
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
let props = parse_props(scope, props_json)?;
let recv = v8::undefined(scope).into(); let recv = v8::undefined(scope).into();
let result = func let html = func
.call(scope, recv, &[props]) .call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?; .ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope)) Ok(html.to_rust_string_lossy(scope))
} }
} }
/// The one crossing where untrusted request text becomes a value inside the
/// isolate. Both steps report that boundary's failure and nothing else: V8
/// refuses a string past its length limit, and its JSON grammar rejects
/// malformed input.
fn parse_props<'s>(
scope: &v8::PinScope<'s, '_>,
props_json: &str,
) -> Result<v8::Local<'s, v8::Value>> {
let text = v8::String::new(scope, props_json)
.ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?;
v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test] fn fixture_bundle() -> String {
async fn renders_react_bundle_in_embedded_v8() { std::fs::read_to_string(concat!(
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js" "/tests/fixture/bundle.js"
)) ))
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step"); .expect("tests/fixture/bundle.js — build it via the fixture's esbuild step")
}
let mut engine = SsrEngine::new(bundle).expect("engine init"); #[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render"); let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#); assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
} }
@@ -117,17 +142,19 @@ mod tests {
// A prop value that would break out of a string-built `renderApp(...)` // A prop value that would break out of a string-built `renderApp(...)`
// call. Through the value-call path it is inert data: it reaches the // call. Through the value-call path it is inert data: it reaches the
// component as a string, never as source. // component as a string, never as source.
let bundle = std::fs::read_to_string(concat!( let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("fixture bundle");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine let html = engine
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#) .render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
.expect("render"); .expect("render");
// The payload rendered as text; it did not execute. // The payload rendered as text; it did not execute.
assert!(html.contains("__pwned")); assert!(html.contains("__pwned"));
} }
#[tokio::test]
async fn a_bundle_without_a_callable_render_app_is_rejected() {
let err = SsrEngine::new("globalThis.renderApp = 7;".to_string())
.map(|_| ())
.expect_err("a bundle whose renderApp is not callable must not build an engine");
assert!(err.to_string().contains("renderApp"), "unexpected: {err}");
}
} }

View File

@@ -1,7 +1,6 @@
import { createElement } from "react" import { createElement } from "react"
// A trivial component: props in, element out. The keystone only needs to prove // Props in, element out. The `id` is the handle the render assertions match on.
// a real React tree renders to HTML inside a bare JS context.
export function Hello({ name }) { export function Hello({ name }) {
return createElement("div", { id: "greeting" }, `Hello, ${name}!`) return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
} }

View File

@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser"
import { createElement } from "react" import { createElement } from "react"
import { Hello } from "./Hello.js" import { Hello } from "./Hello.js"
// The bundle exposes one global the embedded engine calls. No module system at // There is no module system in the embedded engine — it receives a bare
// runtime — the engine receives a bare script that defines `renderApp`. This is // script, so the entry point has to land on `globalThis` for the Rust side to
// the production shape in miniature: build-time bundle, runtime eval. // reach it.
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props)) globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))

View File

@@ -1,14 +1,11 @@
// Proxy for the embedded-V8 runtime: a bare global context with no Node // Runs bundle.js inside a `vm` context holding only the globals listed below,
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call // so the bundle sees the same bare environment the embedded V8 engine gives it.
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
// (bundle defines a global render fn over a bare context) does not.
const fs = require("fs") const fs = require("fs")
const vm = require("vm") const vm = require("vm")
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8") const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
// The minimal host globals React's bundle touches at init / sync render. The // The host globals React's bundle touches at init and during a sync render.
// rusty_v8 engine must provide the same set — this list is the spec for it.
const sandbox = { const sandbox = {
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance, console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
TextEncoder, TextDecoder, TextEncoder, TextDecoder,
@@ -19,11 +16,12 @@ vm.createContext(sandbox)
vm.runInContext(code, sandbox) vm.runInContext(code, sandbox)
const html = sandbox.renderApp({ name: "World" }) const html = sandbox.renderApp({ name: "World" })
console.log("RENDERED:", html)
const expected = '<div id="greeting">Hello, World!</div>' const expected = '<div id="greeting">Hello, World!</div>'
if (html !== expected) { if (html !== expected) {
console.error("MISMATCH — expected:", expected) console.error(`expected ${expected}, got ${html}`)
process.exit(1) process.exit(1)
} }
console.log("OK — React bundle renders in a bare JS context (V8 proxy)") console.log(html)
// The sandbox's MessageChannel holds an open handle, so the event loop never
// drains on its own; exit once the render has been checked.
process.exit(0)

View File

@@ -1,29 +1,19 @@
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection + //! Scans the SSR fixture's authored JS for tokens that only appear when React
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework //! Server Components, the Flight protocol, or a meta-framework SSR runtime is
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight). //! in play. The scan goes red the moment one of them enters the source.
//!
//! React Server Components and the Flight serialization protocol carry use std::path::Path;
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
//! attacker reaches prototype-pollution → RCE.
//!
//! Mizan renders **synchronously from props** — data is fetched server-side
//! through the AFI and passed in, never deserialized from a client payload — so
//! it sits structurally outside that attack surface. This test keeps it there:
//! it goes red the instant any RSC / Flight / streaming surface enters the
//! authored SSR source or its dependencies. Absence is not enough; this is the
//! forcing function that makes re-entry loud.
/// Tokens that only appear when RSC / Flight / streaming rendering is in play. /// Tokens that only appear when RSC / Flight / streaming rendering is in play.
const FORBIDDEN: &[&str] = &[ const FORBIDDEN: &[&str] = &[
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0) // React Server Components / Flight
"react-server-dom", "react-server-dom",
"renderToReadableStream", "renderToReadableStream",
"renderToPipeableStream", "renderToPipeableStream",
"createFromReadableStream", "createFromReadableStream",
"createFromFetch", "createFromFetch",
"use server", "use server",
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter // SSR runtimes / meta-frameworks
"next/", "next/",
"nuxt", "nuxt",
"@sveltejs/kit", "@sveltejs/kit",
@@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[
#[test] #[test]
fn ssr_has_no_rsc_or_flight_surface() { fn ssr_has_no_rsc_or_flight_surface() {
for path in SCANNED { for path in SCANNED {
let Ok(src) = std::fs::read_to_string(path) else { assert!(
continue; // a generated/optional file absent is fine; authored source is the point Path::new(path).is_file(),
}; "{path} is a tracked fixture this scan reads; it is missing",
);
let src = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("reading {path} for the RSC scan: {e}"));
for needle in FORBIDDEN { for needle in FORBIDDEN {
assert!( assert!(
!src.contains(needle), !src.contains(needle),
"RSC/Flight surface {needle:?} found in {path} — forbidden. \ "{needle:?} found in {path}; this scan forbids it",
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
classic renderToString-family only, rendered synchronously from props.",
); );
} }
} }

View File

@@ -13,6 +13,18 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -34,6 +46,17 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kdl"
version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81a29e7b50079ff44549f68c0becb1c73d7f6de2a4ea952da77966daf3d4761e"
dependencies = [
"miette",
"num",
"winnow",
]
[[package]] [[package]]
name = "linkme" name = "linkme"
version = "0.3.36" version = "0.3.36"
@@ -60,13 +83,41 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"unicode-width",
]
[[package]]
name = "minijinja"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
dependencies = [
"memo-map",
"serde",
]
[[package]] [[package]]
name = "mizan-core" name = "mizan-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"indoc", "indoc",
"kdl",
"linkme", "linkme",
"minijinja",
"mizan-macros", "mizan-macros",
"serde", "serde",
"serde_json", "serde_json",
@@ -82,6 +133,79 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -166,6 +290,21 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "winnow"
version = "0.6.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"

View File

@@ -2,11 +2,12 @@
name = "mizan-core" name = "mizan-core"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/." description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry."
license = "Elastic-2.0" license = "Elastic-2.0"
[dependencies] [dependencies]
linkme = "0.3" linkme = "0.3"
minijinja = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
async-trait = "0.1" async-trait = "0.1"
@@ -14,3 +15,4 @@ mizan-macros = { path = "../mizan-rust-macros" }
[dev-dependencies] [dev-dependencies]
indoc = "2" indoc = "2"
kdl = "6"

View File

@@ -1,200 +1,313 @@
//! Cross-function invariant verification — fails at `build_ir()` time, which //! Cross-function invariant checks over the registered graph.
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
//! graph-level inconsistencies surface before any client artifact is emitted.
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape}; use crate::ir::{NamedType, Primitive, TypeShape};
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES}; use crate::registry::{CONTEXTS, FUNCTIONS};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::fmt;
use std::sync::OnceLock;
/// Walk the registered types and find the named type's shape. Used by both /// A structural fingerprint of a type, with every reference resolved through
/// graph-check and runtime merge resolution. /// to the shape it names. Two types are interchangeable exactly when their
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> { /// fingerprints are equal, so comparison is one derived `==` instead of a
for entry in TYPES { /// pairwise walk over both shape enums.
if entry.name == name { #[derive(PartialEq)]
return Some((entry.shape_fn)()); enum Canonical {
} Record(Vec<CanonicalField>),
} Aliased(Box<Canonical>),
None NamedEnum(Vec<&'static str>),
Primitive(&'static str),
List(Box<Canonical>),
Optional(Box<Canonical>),
InlineEnum(Vec<&'static str>),
Union(Vec<Canonical>),
} }
/// Merge-compatibility on named types. A mutation return `value` can #[derive(PartialEq)]
/// splice into a context slot `slot` when any of three shapes hold — struct CanonicalField {
/// matches Python's `types_match_for_merge`: name: &'static str,
/// * direct: `slot` shape equals `value` shape → replace required: bool,
/// * upsert: `slot` is `list[T]`, `value` is `T` → upsert by id shape: Canonical,
/// * list-replace: `slot` is `list[T]`, `value` is `list[T]`
///
/// The first argument is the slot (context member's output type); the
/// second is the value (mutation's output type).
pub(crate) fn types_match(slot: &NamedType, value: &NamedType) -> bool {
if named_shapes_equal(slot, value) {
return true;
}
// Upsert: slot is `Alias(List(T))`, value is `T`-shaped.
if let NamedType::Alias(TypeShape::List(elem)) = slot {
if shape_matches_named(elem, value) {
return true;
}
}
false
} }
fn named_shapes_equal(a: &NamedType, b: &NamedType) -> bool { fn canonical_named(named: &NamedType) -> Canonical {
match (a, b) { match named {
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb), NamedType::Struct(fields) => Canonical::Record(
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb), fields
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb, .iter()
_ => false, .map(|f| CanonicalField {
} name: f.name,
} required: f.required,
shape: canonical_shape(&f.shape),
/// True when a `TypeShape` (the slot's list-element) describes the same
/// shape as a `NamedType` (the mutation's full output).
fn shape_matches_named(shape: &TypeShape, named: &NamedType) -> bool {
match shape {
TypeShape::Ref(name) => {
if let Some(referenced) = resolve_type_shape(name) {
named_shapes_equal(&referenced, named)
} else {
false
}
}
_ => false,
}
}
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(fa, fb)| {
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
}) })
} .collect(),
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
match (a, b) {
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
std::mem::discriminant(pa) == std::mem::discriminant(pb)
}
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
// Refs match iff the named types they reference match.
match (resolve_type_shape(na), resolve_type_shape(nb)) {
(Some(ta), Some(tb)) => types_match(&ta, &tb),
_ => na == nb,
}
}
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
}
_ => false,
}
}
/// Panic with a structured message if the registered function graph is
/// inconsistent. Called from `build_ir()`.
pub fn verify_invariants() {
check_affects_targets();
check_merge_targets();
check_shared_param_types();
}
fn check_affects_targets() {
for fn_spec in FUNCTIONS {
for affect in fn_spec.affects() {
if let AffectTarget::Context(name) = affect {
if lookup_context(name).is_none() {
panic!(
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
fn_spec.name(),
name,
name,
);
}
}
}
}
}
fn check_merge_targets() {
for fn_spec in FUNCTIONS {
for merge_target in fn_spec.merge() {
let ctx_entry = match lookup_context(merge_target) {
Some(c) => c,
None => panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
fn_spec.name(),
merge_target,
), ),
}; NamedType::Alias(inner) => Canonical::Aliased(Box::new(canonical_shape(inner))),
NamedType::Enum(variants) => Canonical::NamedEnum(variants.clone()),
}
}
let mutation_output = fn_spec.output_type(); fn canonical_shape(shape: &TypeShape) -> Canonical {
let mutation_shape = match resolve_type_shape(mutation_output) { match shape {
Some(s) => s, TypeShape::Primitive(p) => Canonical::Primitive(p.name()),
None => panic!( TypeShape::Ref { shape, .. } => canonical_named(&shape()),
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.", TypeShape::List(inner) => Canonical::List(Box::new(canonical_shape(inner))),
fn_spec.name(), mutation_output, TypeShape::Optional(inner) => Canonical::Optional(Box::new(canonical_shape(inner))),
TypeShape::Enum(variants) => Canonical::InlineEnum(variants.clone()),
TypeShape::Union(branches) => {
Canonical::Union(branches.iter().map(canonical_shape).collect())
}
}
}
/// Merge-compatibility on named types. A mutation return `value` can splice
/// into a context slot `slot` when either shape holds:
/// * direct: `slot` and `value` have the same fingerprint → replace
/// * upsert: `slot` is `list[T]` and `value` is `T` → upsert by id
///
/// The first argument is the slot (context member's output type); the second
/// is the value (mutation's output type).
fn types_match(slot: &NamedType, value: &NamedType) -> bool {
let value_form = canonical_named(value);
if canonical_named(slot) == value_form {
return true;
}
match slot {
NamedType::Alias(inner) => match inner {
TypeShape::List(elem) => canonical_shape(elem) == value_form,
TypeShape::Primitive(_)
| TypeShape::Ref { .. }
| TypeShape::Optional(_)
| TypeShape::Enum(_)
| TypeShape::Union(_) => false,
},
NamedType::Struct(_) | NamedType::Enum(_) => false,
}
}
/// One `merge` declaration read off the registry and resolved: the mutation
/// that declares it, the context it names, and the context member whose output
/// the mutation's return value splices into.
pub(crate) struct ResolvedMerge {
pub function: &'static str,
pub context: &'static str,
pub slot: &'static str,
}
/// The context members whose output a mutation's return value can splice into,
/// accumulated one candidate at a time. A `merge` declaration carries a usable
/// slot exactly when the walk ends on `Unique`.
enum SlotMatch {
Absent,
Unique(&'static str),
Ambiguous(Vec<&'static str>),
}
impl SlotMatch {
fn with(self, candidate: &'static str) -> Self {
match self {
SlotMatch::Absent => SlotMatch::Unique(candidate),
SlotMatch::Unique(first) => SlotMatch::Ambiguous(vec![first, candidate]),
SlotMatch::Ambiguous(mut members) => {
members.push(candidate);
SlotMatch::Ambiguous(members)
}
}
}
}
/// The ways a registered graph fails to hold together.
enum GraphDefect {
NoMergeSlot {
function: &'static str,
context: &'static str,
output_type: &'static str,
},
AmbiguousMergeSlot {
function: &'static str,
context: &'static str,
output_type: &'static str,
members: Vec<&'static str>,
},
DivergentParamType {
context: &'static str,
param: &'static str,
first_fn: &'static str,
first_type: &'static str,
second_fn: &'static str,
second_type: &'static str,
},
}
impl fmt::Display for GraphDefect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GraphDefect::NoMergeSlot {
function,
context,
output_type,
} => write!(
f,
"function `{function}` declares `merge = \"{context}\"` but no member of that \
context has output type `{output_type}`. Add a context member returning \
`{output_type}`, or declare `affects` for plain refetch."
), ),
}; GraphDefect::AmbiguousMergeSlot {
let mut matches: Vec<&'static str> = Vec::new(); function,
context,
output_type,
members,
} => write!(
f,
"function `{function}` declares `merge = \"{context}\"` but members ({}) all \
share output type `{output_type}`. Merge resolution needs exactly one match. \
Distinguish the outputs, or declare `affects` for plain refetch.",
members.join(", ")
),
GraphDefect::DivergentParamType {
context,
param,
first_fn,
first_type,
second_fn,
second_type,
} => write!(
f,
"context `{context}` has a parameter `{param}` whose type diverges across \
members. Function `{first_fn}` declares it as `{first_type}`, function \
`{second_fn}` declares it as `{second_type}`. A shared param has one type \
across the whole context."
),
}
}
}
/// Every defect on its own bulleted line, under one heading.
struct GraphReport<'a>(&'a [GraphDefect]);
impl fmt::Display for GraphReport<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Mizan graph-check: the registered function graph is inconsistent."
)?;
for defect in self.0 {
writeln!(f, " - {defect}")?;
}
Ok(())
}
}
/// Every `merge` declaration that resolved to exactly one slot, plus every way
/// the graph failed to hold together.
struct GraphAnalysis {
merges: Vec<ResolvedMerge>,
defects: Vec<GraphDefect>,
}
static ANALYSIS: OnceLock<GraphAnalysis> = OnceLock::new();
/// `FUNCTIONS` and `CONTEXTS` are link-time data, so the walk yields the same
/// answer for every caller and runs once.
fn analysis() -> &'static GraphAnalysis {
ANALYSIS.get_or_init(analyze)
}
fn analyze() -> GraphAnalysis {
let mut merges = Vec::new();
let mut defects = Vec::new();
for fn_spec in FUNCTIONS {
let mutation_shape = fn_spec.output_shape();
for &context in fn_spec.merge() {
match match_slot(context, &mutation_shape) {
SlotMatch::Unique(slot) => merges.push(ResolvedMerge {
function: fn_spec.name(),
context,
slot,
}),
SlotMatch::Absent => defects.push(GraphDefect::NoMergeSlot {
function: fn_spec.name(),
context,
output_type: fn_spec.output_type(),
}),
SlotMatch::Ambiguous(members) => defects.push(GraphDefect::AmbiguousMergeSlot {
function: fn_spec.name(),
context,
output_type: fn_spec.output_type(),
members,
}),
}
}
}
defects.extend(divergent_param_types());
GraphAnalysis { merges, defects }
}
/// The members of `context_name` whose output type a value of `mutation_shape`
/// splices into.
fn match_slot(context_name: &'static str, mutation_shape: &NamedType) -> SlotMatch {
let mut matched = SlotMatch::Absent;
for candidate in FUNCTIONS { for candidate in FUNCTIONS {
if candidate.context() != Some(ctx_entry.name) { if candidate.context() != Some(context_name) {
continue; continue;
} }
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) { if types_match(&candidate.output_shape(), mutation_shape) {
if types_match(&candidate_shape, &mutation_shape) { matched = matched.with(candidate.name());
matches.push(candidate.name());
}
}
}
if matches.is_empty() {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
fn_spec.name(), merge_target, mutation_output, mutation_output,
);
}
if matches.len() > 1 {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
);
}
} }
} }
matched
} }
fn check_shared_param_types() { /// Params that one context's members declare under the same name but with
/// different primitives.
fn divergent_param_types() -> Vec<GraphDefect> {
let mut defects = Vec::new();
for ctx in CONTEXTS { for ctx in CONTEXTS {
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)> let mut by_name: HashMap<&'static str, (Primitive, &'static str)> = HashMap::new();
= std::collections::HashMap::new();
for fn_spec in FUNCTIONS { for fn_spec in FUNCTIONS {
if fn_spec.context() != Some(ctx.name) { if fn_spec.context() != Some(ctx.name) {
continue; continue;
} }
for p in fn_spec.input_params() { for p in fn_spec.input_params() {
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) { match by_name.entry(p.name) {
if std::mem::discriminant(prev_primitive) Entry::Occupied(seen) => {
!= std::mem::discriminant(&p.primitive) let (first_primitive, first_fn) = *seen.get();
{ if first_primitive != p.primitive {
panic!( defects.push(GraphDefect::DivergentParamType {
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \ context: ctx.name,
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \ param: p.name,
Shared params must have one type across the whole context.", first_fn,
ctx.name, p.name, first_type: first_primitive.name(),
prev_fn, prev_primitive.name(), second_fn: fn_spec.name(),
fn_spec.name(), p.primitive.name(), second_type: p.primitive.name(),
); });
} }
} else { }
by_name.insert(p.name, (p.primitive, fn_spec.name())); Entry::Vacant(slot) => {
slot.insert((p.primitive, fn_spec.name()));
} }
} }
} }
} }
}
defects
}
/// Panic with the full defect report when the registered function graph is
/// inconsistent.
pub fn verify_invariants() {
let defects = &analysis().defects;
if !defects.is_empty() {
panic!("{}", GraphReport(defects));
}
}
/// The merges `function` declares. Reading them verifies the graph first, so a
/// declaration that resolved to no slot is reported rather than passed over.
pub(crate) fn merges_for(function: &str) -> impl Iterator<Item = &'static ResolvedMerge> + '_ {
verify_invariants();
analysis()
.merges
.iter()
.filter(move |resolved| resolved.function == function)
} }

View File

@@ -1,13 +1,10 @@
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1. //! The IR data model the KDL emitter walks: named types, inline type shapes,
//! //! and the descriptors a registered function or channel carries.
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
//! side produces byte-equivalent KDL to the Python emitter against the same
//! function registry.
/// A named type that appears in the IR's `type "<Name>" { ... }` section. /// A named type that appears in the IR's `type "<Name>" { ... }` section.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum NamedType { pub enum NamedType {
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record. /// `type "X" { struct { field ... } }` — a record.
Struct(Vec<StructField>), Struct(Vec<StructField>),
/// `type "X" { alias { <type-child> } }` — a named wrapper around an /// `type "X" { alias { <type-child> } }` — a named wrapper around an
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`. /// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
@@ -21,14 +18,20 @@ pub enum NamedType {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TypeShape { pub enum TypeShape {
Primitive(Primitive), Primitive(Primitive),
Ref(&'static str), /// A reference to a named type. `shape` is the referent's own shape
/// constructor, so resolving a reference never consults a registry and
/// never fails.
Ref {
name: &'static str,
shape: fn() -> NamedType,
},
List(Box<TypeShape>), List(Box<TypeShape>),
Optional(Box<TypeShape>), Optional(Box<TypeShape>),
Enum(Vec<&'static str>), Enum(Vec<&'static str>),
Union(Vec<TypeShape>), Union(Vec<TypeShape>),
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive { pub enum Primitive {
Integer, Integer,
Number, Number,
@@ -64,8 +67,8 @@ pub enum DefaultValue {
Null, Null,
} }
/// One descriptor of what a mutation `affects`. Mirrors Python's /// One descriptor of what a mutation `affects` — either a named context or a
/// `_normalize_affects` shape — either a named context or a named function. /// named function.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AffectTarget { pub enum AffectTarget {
Context(&'static str), Context(&'static str),
@@ -75,6 +78,37 @@ pub enum AffectTarget {
}, },
} }
/// One payload slot of a channel. Direction is named from the client's point
/// of view: a `ClientMessage` travels client → server, a `ServerMessage`
/// travels server → client.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelSlotKind {
Params,
ClientMessage,
ServerMessage,
}
impl ChannelSlotKind {
/// The KDL child-node name the slot emits under.
pub fn node_name(self) -> &'static str {
match self {
ChannelSlotKind::Params => "params",
ChannelSlotKind::ClientMessage => "client-message",
ChannelSlotKind::ServerMessage => "server-message",
}
}
/// The suffix appended to the channel's Pascal stem to name the slot's
/// emitted type.
pub fn type_suffix(self) -> &'static str {
match self {
ChannelSlotKind::Params => "Params",
ChannelSlotKind::ClientMessage => "ClientMessage",
ChannelSlotKind::ServerMessage => "ServerMessage",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport { pub enum Transport {
Http, Http,

View File

@@ -1,17 +1,104 @@
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`. //! KDL emitter — collects the registries (named types, functions, contexts,
//! //! channels) into a KDL node tree and renders it through
//! The Python emitter is the spec; this is the second implementation under //! `templates/ir.kdl.jinja`.
//! the same contract. Any divergence is a bug here, not a contract change.
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape}; use crate::ir::{
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES}; AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, TypeShape,
};
use crate::registry::{CHANNELS, CONTEXTS, FUNCTIONS, TYPES};
use crate::traits::FunctionSpec; use crate::traits::FunctionSpec;
use minijinja::value::ViaDeserialize;
use minijinja::{context, Environment};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
const INDENT: &str = " "; const INDENT: &str = " ";
const IR_TEMPLATE: &str = include_str!("../templates/ir.kdl.jinja");
/// Escape a string for KDL — same escape set as the Python emitter. /// A KDL scalar, carried structurally so the template's `kdl` filter — not
fn kdl_string(s: &str) -> String { /// the node builders — decides its written form.
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "kind", content = "v")]
enum KdlValue {
Str(String),
Bool(bool),
Integer(i64),
Number(f64),
Null,
}
impl KdlValue {
fn str(s: &str) -> Self {
KdlValue::Str(s.to_string())
}
fn of_default(v: &DefaultValue) -> Self {
match v {
DefaultValue::Null => KdlValue::Null,
DefaultValue::Boolean(b) => KdlValue::Bool(*b),
DefaultValue::Integer(i) => KdlValue::Integer(*i),
DefaultValue::Number(f) => KdlValue::Number(*f),
DefaultValue::String(s) => KdlValue::str(s),
}
}
}
#[derive(Serialize)]
struct KdlProp {
name: &'static str,
value: KdlValue,
}
/// One KDL node: its own line, plus a brace-delimited child block when
/// `block` is set. `indent` is the literal prefix its line carries.
#[derive(Serialize)]
struct KdlNode {
indent: String,
name: &'static str,
args: Vec<KdlValue>,
props: Vec<KdlProp>,
block: bool,
children: Vec<KdlNode>,
}
impl KdlNode {
fn new(depth: usize, name: &'static str) -> Self {
Self {
indent: INDENT.repeat(depth),
name,
args: Vec::new(),
props: Vec::new(),
block: false,
children: Vec::new(),
}
}
fn arg(mut self, value: KdlValue) -> Self {
self.args.push(value);
self
}
fn args(mut self, values: impl IntoIterator<Item = KdlValue>) -> Self {
self.args.extend(values);
self
}
fn prop(mut self, name: &'static str, value: KdlValue) -> Self {
self.props.push(KdlProp { name, value });
self
}
fn block(mut self, children: Vec<KdlNode>) -> Self {
self.block = true;
self.children = children;
self
}
}
/// The `kdl` template filter — writes one scalar in KDL surface syntax.
fn render_kdl_value(value: ViaDeserialize<KdlValue>) -> String {
match &*value {
KdlValue::Str(s) => {
let mut out = String::with_capacity(s.len() + 2); let mut out = String::with_capacity(s.len() + 2);
out.push('"'); out.push('"');
for c in s.chars() { for c in s.chars() {
@@ -26,35 +113,29 @@ fn kdl_string(s: &str) -> String {
} }
out.push('"'); out.push('"');
out out
}
fn kdl_bool(b: bool) -> &'static str {
if b {
"#true"
} else {
"#false"
} }
} KdlValue::Bool(b) => {
if *b {
fn kdl_default(v: &DefaultValue) -> String { "#true".to_string()
match v { } else {
DefaultValue::Null => "#null".into(), "#false".to_string()
DefaultValue::Boolean(b) => kdl_bool(*b).into(), }
DefaultValue::Integer(i) => i.to_string(), }
DefaultValue::Number(f) => { KdlValue::Integer(i) => i.to_string(),
// Match Python's `repr(float)` for whole-number-equal-but-float KdlValue::Number(f) => {
// values: e.g. 1.0 → "1.0", not "1". // A whole-valued float still writes with its fractional part, so
// `1.0` does not collapse into the integer spelling `1`.
if f.fract() == 0.0 && f.is_finite() { if f.fract() == 0.0 && f.is_finite() {
format!("{f:.1}") format!("{f:.1}")
} else { } else {
f.to_string() f.to_string()
} }
} }
DefaultValue::String(s) => kdl_string(s), KdlValue::Null => "#null".to_string(),
} }
} }
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`. /// Convert snake_case to camelCase.
pub fn snake_to_camel(name: &str) -> String { pub fn snake_to_camel(name: &str) -> String {
let normalized = name.replace('.', "_").replace('-', "_"); let normalized = name.replace('.', "_").replace('-', "_");
let mut parts = normalized.split('_'); let mut parts = normalized.split('_');
@@ -75,208 +156,159 @@ pub fn snake_to_camel(name: &str) -> String {
out out
} }
struct Emitter<'a> { /// The PascalCase stem every emitted type name for `wire_name` is built on:
lines: Vec<String>, /// split on `[._-]`, then title-case each part, where a character is
/// Types whose references should be substituted with their inline /// uppercased only when the character before it is not a letter.
/// shape at the use site (and which don't emit as their own pub fn wire_to_pascal(wire_name: &str) -> String {
/// `type "X" { ... }` entries). Populated from `IrSnapshot::inlines`. let mut out = String::with_capacity(wire_name.len());
for part in wire_name.split(['.', '_', '-']) {
let mut prev_is_letter = false;
for c in part.chars() {
if prev_is_letter {
out.extend(c.to_lowercase());
} else {
out.extend(c.to_uppercase());
}
prev_is_letter = c.is_alphabetic();
}
}
out
}
/// Builds the node tree for one document.
struct NodeBuilder<'a> {
/// Types whose references are substituted with their inline shape at the
/// use site, and which emit no `type "X" { ... }` entry of their own.
inlines: &'a BTreeMap<&'static str, TypeShape>, inlines: &'a BTreeMap<&'static str, TypeShape>,
} }
impl<'a> Emitter<'a> { impl NodeBuilder<'_> {
fn new(inlines: &'a BTreeMap<&'static str, TypeShape>) -> Self { fn type_child(&self, depth: usize, shape: &TypeShape) -> KdlNode {
Self {
lines: Vec::new(),
inlines,
}
}
fn prefix(&self, indent: usize) -> String {
INDENT.repeat(indent)
}
fn leaf(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
self.lines.push(line);
}
fn open(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
line.push_str(" {");
self.lines.push(line);
}
fn close(&mut self, indent: usize) {
let mut line = self.prefix(indent);
line.push('}');
self.lines.push(line);
}
fn blank(&mut self) {
self.lines.push(String::new());
}
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
match shape { match shape {
TypeShape::Primitive(p) => { TypeShape::Primitive(p) => {
let name = kdl_string(p.name()); KdlNode::new(depth, "primitive").arg(KdlValue::str(p.name()))
self.leaf(indent, &["primitive", &name]);
}
TypeShape::Ref(name) => {
// Inline-substitute when the referenced type is a
// primitive-alias or string-enum. Matches Python's
// Pydantic Literal/alias inlining.
if let Some(inline_shape) = self.inlines.get(name).cloned() {
self.emit_type_child(indent, &inline_shape);
return;
}
let n = kdl_string(name);
self.leaf(indent, &["ref", &n]);
} }
TypeShape::Ref { name, .. } => match self.inlines.get(name) {
Some(inline_shape) => self.type_child(depth, &inline_shape.clone()),
None => KdlNode::new(depth, "ref").arg(KdlValue::str(name)),
},
TypeShape::List(inner) => { TypeShape::List(inner) => {
self.open(indent, &["list"]); KdlNode::new(depth, "list").block(vec![self.type_child(depth + 1, inner)])
self.emit_type_child(indent + 1, inner);
self.close(indent);
} }
TypeShape::Optional(inner) => { TypeShape::Optional(inner) => {
self.open(indent, &["optional"]); KdlNode::new(depth, "optional").block(vec![self.type_child(depth + 1, inner)])
self.emit_type_child(indent + 1, inner);
self.close(indent);
} }
TypeShape::Enum(variants) => { TypeShape::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()]; KdlNode::new(depth, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent, &line);
}
TypeShape::Union(branches) => {
self.open(indent, &["union"]);
for b in branches {
self.emit_type_child(indent + 1, b);
}
self.close(indent);
} }
TypeShape::Union(branches) => KdlNode::new(depth, "union").block(
branches
.iter()
.map(|b| self.type_child(depth + 1, b))
.collect(),
),
} }
} }
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) { fn named_type(&self, depth: usize, name: &str, body: &NamedType) -> KdlNode {
let name_lit = kdl_string(name); let inner = match body {
self.open(indent, &["type", &name_lit]); NamedType::Struct(fields) => KdlNode::new(depth + 1, "struct").block(
match body { fields
NamedType::Struct(fields) => { .iter()
self.open(indent + 1, &["struct"]); .map(|field| self.struct_field(depth + 2, field))
for field in fields { .collect(),
self.emit_struct_field(indent + 2, field); ),
} NamedType::Alias(shape) => {
self.close(indent + 1); KdlNode::new(depth + 1, "alias").block(vec![self.type_child(depth + 2, shape)])
}
NamedType::Alias(inner) => {
self.open(indent + 1, &["alias"]);
self.emit_type_child(indent + 2, inner);
self.close(indent + 1);
} }
NamedType::Enum(variants) => { NamedType::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()]; KdlNode::new(depth + 1, "enum").args(variants.iter().map(|v| KdlValue::str(v)))
for v in variants {
parts.push(kdl_string(v));
} }
let line: Vec<&str> = parts.iter().map(String::as_str).collect(); };
self.leaf(indent + 1, &line); KdlNode::new(depth, "type")
} .arg(KdlValue::str(name))
} .block(vec![inner])
self.close(indent);
} }
fn emit_struct_field(&mut self, indent: usize, field: &StructField) { fn struct_field(&self, depth: usize, field: &StructField) -> KdlNode {
let name = kdl_string(field.name); let mut node = KdlNode::new(depth, "field").arg(KdlValue::str(field.name));
let mut header: Vec<String> = vec!["field".into(), name];
if !field.required { if !field.required {
header.push(format!("required={}", kdl_bool(false))); node = node.prop("required", KdlValue::Bool(false));
if let Some(default) = &field.default { if let Some(default) = &field.default {
header.push(format!("default={}", kdl_default(default))); node = node.prop("default", KdlValue::of_default(default));
} }
} }
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect(); node.block(vec![self.type_child(depth + 1, &field.shape)])
self.open(indent, &line_parts);
self.emit_type_child(indent + 1, &field.shape);
self.close(indent);
} }
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) { fn function(&self, depth: usize, fn_spec: &dyn FunctionSpec) -> KdlNode {
let name = kdl_string(fn_spec.name()); let inner = depth + 1;
self.open(indent, &["function", &name]); let mut children = vec![
KdlNode::new(inner, "camel").arg(KdlValue::str(fn_spec.camel_name())),
let camel = kdl_string(fn_spec.camel_name()); KdlNode::new(inner, "has-input").arg(KdlValue::Bool(fn_spec.has_input())),
self.leaf(indent + 1, &["camel", &camel]); ];
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
if let Some(input_type) = fn_spec.input_type() { if let Some(input_type) = fn_spec.input_type() {
let lit = kdl_string(input_type); children.push(KdlNode::new(inner, "input").arg(KdlValue::str(input_type)));
self.leaf(indent + 1, &["input", &lit]);
} }
let output_lit = kdl_string(fn_spec.output_type()); children.push(KdlNode::new(inner, "output").arg(KdlValue::str(fn_spec.output_type())));
self.leaf(indent + 1, &["output", &output_lit]);
if fn_spec.output_nullable() { if fn_spec.output_nullable() {
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]); children.push(KdlNode::new(inner, "output-nullable").arg(KdlValue::Bool(true)));
} }
let transport_lit = kdl_string(fn_spec.transport().name()); children
self.leaf(indent + 1, &["transport", &transport_lit]); .push(KdlNode::new(inner, "transport").arg(KdlValue::str(fn_spec.transport().name())));
if let Some(ctx) = fn_spec.context() { if let Some(ctx) = fn_spec.context() {
let lit = kdl_string(ctx); children.push(KdlNode::new(inner, "context").arg(KdlValue::str(ctx)));
self.leaf(indent + 1, &["context", &lit]);
} }
for affect in fn_spec.affects() { for affect in fn_spec.affects() {
// Mirror Python's behavior: only context-typed affects make it match affect {
// into the KDL `affects` leaf. Function-typed affects are // The `affects` leaf names a context; a function-typed target
// reserved for a future IR extension. // has no leaf in the document.
if let crate::ir::AffectTarget::Context(name) = affect { AffectTarget::Context(name) => {
let lit = kdl_string(name); children.push(KdlNode::new(inner, "affects").arg(KdlValue::str(name)));
self.leaf(indent + 1, &["affects", &lit]); }
AffectTarget::Function { .. } => {}
} }
} }
for merge in fn_spec.merge() { for merge in fn_spec.merge() {
let lit = kdl_string(merge); children.push(KdlNode::new(inner, "merge").arg(KdlValue::str(merge)));
self.leaf(indent + 1, &["merge", &lit]);
} }
if fn_spec.is_form() { if fn_spec.is_form() {
self.leaf(indent + 1, &["is-form", kdl_bool(true)]); children.push(KdlNode::new(inner, "is-form").arg(KdlValue::Bool(true)));
if let Some(form_name) = fn_spec.form_name() { if let Some(form_name) = fn_spec.form_name() {
let lit = kdl_string(form_name); children.push(KdlNode::new(inner, "form-name").arg(KdlValue::str(form_name)));
self.leaf(indent + 1, &["form-name", &lit]);
} }
if let Some(form_role) = fn_spec.form_role() { if let Some(form_role) = fn_spec.form_role() {
let lit = kdl_string(form_role); children.push(KdlNode::new(inner, "form-role").arg(KdlValue::str(form_role)));
self.leaf(indent + 1, &["form-role", &lit]);
} }
} }
self.close(indent); KdlNode::new(depth, "function")
.arg(KdlValue::str(fn_spec.name()))
.block(children)
} }
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) { fn context(
let name_lit = kdl_string(ctx_name); &self,
self.open(indent, &["context", &name_lit]); depth: usize,
ctx_name: &str,
members: &[&'static dyn FunctionSpec],
) -> KdlNode {
let inner = depth + 1;
let mut children: Vec<KdlNode> = members
.iter()
.map(|fn_spec| KdlNode::new(inner, "function").arg(KdlValue::str(fn_spec.name())))
.collect();
// Function membership in registration order. // Params collected across every member, keyed so they emit
for fn_spec in members { // alphabetized by param name.
let lit = kdl_string(fn_spec.name());
self.leaf(indent + 1, &["function", &lit]);
}
// Param info — collect across every member, then emit alphabetized
// by param name to match Python.
struct ParamSlot { struct ParamSlot {
primitive: Primitive, primitive: Primitive,
shared_by: Vec<&'static str>, shared_by: Vec<&'static str>,
@@ -295,120 +327,163 @@ impl<'a> Emitter<'a> {
let member_count = members.len(); let member_count = members.len();
for (param_name, slot) in params.iter() { for (param_name, slot) in params.iter() {
let name_lit = kdl_string(param_name); let mut param_children = vec![
self.open(indent + 1, &["param", &name_lit]); KdlNode::new(inner + 1, "type").arg(KdlValue::str(slot.primitive.name())),
let type_lit = kdl_string(slot.primitive.name()); KdlNode::new(inner + 1, "required")
self.leaf(indent + 2, &["type", &type_lit]); .arg(KdlValue::Bool(slot.shared_by.len() == member_count)),
let required = slot.shared_by.len() == member_count; ];
self.leaf(indent + 2, &["required", kdl_bool(required)]);
for sharer in &slot.shared_by { for sharer in &slot.shared_by {
let lit = kdl_string(sharer); param_children
self.leaf(indent + 2, &["shared-by", &lit]); .push(KdlNode::new(inner + 1, "shared-by").arg(KdlValue::str(sharer)));
} }
self.close(indent + 1); children.push(
KdlNode::new(inner, "param")
.arg(KdlValue::str(param_name))
.block(param_children),
);
} }
self.close(indent); KdlNode::new(depth, "context")
.arg(KdlValue::str(ctx_name))
.block(children)
} }
fn into_string(mut self) -> String { fn channel(&self, depth: usize, channel: &ChannelRecord) -> KdlNode {
// Trim trailing blanks, then add a single terminating newline. let inner = depth + 1;
while matches!(self.lines.last(), Some(s) if s.is_empty()) { let mut children =
self.lines.pop(); vec![KdlNode::new(inner, "pascal-name").arg(KdlValue::str(&channel.pascal_name))];
for slot in &channel.slots {
children.push(
KdlNode::new(inner, slot.kind.node_name()).arg(KdlValue::str(&slot.type_name)),
);
} }
let mut out = self.lines.join("\n"); KdlNode::new(depth, "channel")
out.push('\n'); .arg(KdlValue::str(channel.name))
out .block(children)
} }
} }
/// One channel as the document carries it: the wire name, the Pascal stem its
/// slot type names are built on, and the slots it declares.
pub(crate) struct ChannelRecord {
pub name: &'static str,
pub pascal_name: String,
pub slots: Vec<ChannelSlotRecord>,
}
pub(crate) struct ChannelSlotRecord {
pub kind: ChannelSlotKind,
pub type_name: String,
}
/// Collected typed registries view used by `build_ir`. /// Collected typed registries view used by `build_ir`.
pub(crate) struct IrSnapshot { pub(crate) struct IrSnapshot {
pub types: BTreeMap<&'static str, NamedType>, pub types: BTreeMap<String, NamedType>,
pub functions: Vec<&'static dyn FunctionSpec>, pub functions: Vec<&'static dyn FunctionSpec>,
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>, pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
/// Types that inline to a `TypeShape` at every reference site rather pub channels: Vec<ChannelRecord>,
/// than emitting as their own `type "X" { ... }` entry. Populated from /// Types that inline to a `TypeShape` at every reference site rather than
/// `Alias(Primitive(_))` and `Enum` named types — both are /// emitting a `type "X" { ... }` entry: `Alias(Primitive(_))` and `Enum`,
/// information-zero indirections that the codegen consumer doesn't /// both of which carry no structure a named entry would add.
/// gain anything from naming. Matches the Python emitter's behavior
/// (Pydantic `FigureId = str` and `Literal["..."]` inline; they don't
/// materialize as named types).
pub inlines: BTreeMap<&'static str, TypeShape>, pub inlines: BTreeMap<&'static str, TypeShape>,
} }
impl IrSnapshot { impl IrSnapshot {
pub(crate) fn collect() -> Self { pub(crate) fn collect() -> Self {
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`. // Types: alphabetized, which is the document's canonical ordering.
let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new(); let mut all_types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
for entry in TYPES { for entry in TYPES {
all_types.insert(entry.name, (entry.shape_fn)()); all_types.insert(entry.name, (entry.shape_fn)());
} }
// Partition into emit-candidate types vs inlines. An inline is a // Partition into emit-candidate types vs inlines.
// named type whose shape collapses to a single `TypeShape` at the
// field site — primitive aliases and string enums.
let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new(); let mut candidates: BTreeMap<&'static str, NamedType> = BTreeMap::new();
let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new(); let mut inlines: BTreeMap<&'static str, TypeShape> = BTreeMap::new();
for (name, body) in all_types { for (name, body) in all_types {
match &body { match body {
NamedType::Alias(TypeShape::Primitive(p)) => {
inlines.insert(name, TypeShape::Primitive(*p));
}
NamedType::Enum(variants) => { NamedType::Enum(variants) => {
inlines.insert(name, TypeShape::Enum(variants.clone())); inlines.insert(name, TypeShape::Enum(variants));
} }
_ => { NamedType::Alias(TypeShape::Primitive(p)) => {
candidates.insert(name, body); inlines.insert(name, TypeShape::Primitive(p));
}
NamedType::Alias(shape) => {
candidates.insert(name, NamedType::Alias(shape));
}
NamedType::Struct(fields) => {
candidates.insert(name, NamedType::Struct(fields));
} }
} }
} }
// Tree-shake: keep only types reachable from a registered function's // Channels: alphabetical by wire name, each declared slot's type
// input/output. The function macro registers canonical-named // named `<Pascal><Slot>`. The slot shapes enter the type section
// entries (e.g. `userPrefsOutput`); derive registers original-named // directly, so they are emitted whether or not a function reaches
// entries (`UserPrefs`, `BrushSettings`, …). Only those reached // them.
// via Ref-walk from a function's input/output names belong in the let mut channel_entries: Vec<&'static crate::registry::ChannelEntry> =
// emitted IR. Mirrors Python's `_collect_named_types`. CHANNELS.iter().collect();
channel_entries.sort_by_key(|c| c.name);
let mut channels: Vec<ChannelRecord> = Vec::new();
let mut channel_types: Vec<(String, NamedType)> = Vec::new();
for entry in channel_entries {
let pascal_name = wire_to_pascal(entry.name);
let mut slots: Vec<ChannelSlotRecord> = Vec::new();
for slot in entry.slots {
let type_name = format!("{pascal_name}{}", slot.kind.type_suffix());
channel_types.push((type_name.clone(), (slot.shape_fn)()));
slots.push(ChannelSlotRecord {
kind: slot.kind,
type_name,
});
}
channels.push(ChannelRecord {
name: entry.name,
pascal_name,
slots,
});
}
// Roots of the tree-shake: every non-private function's input and
// output name, plus every name a channel slot's shape refs.
let mut reachable: std::collections::HashSet<&'static str> = let mut reachable: std::collections::HashSet<&'static str> =
std::collections::HashSet::new(); std::collections::HashSet::new();
let mut frontier: Vec<&'static str> = Vec::new();
for fn_spec in FUNCTIONS { for fn_spec in FUNCTIONS {
if fn_spec.private() { if fn_spec.private() {
continue; continue;
} }
if let Some(input_name) = fn_spec.input_type() { if let Some(input_name) = fn_spec.input_type() {
if reachable.insert(input_name) { reachable.insert(input_name);
frontier.push(input_name);
} }
reachable.insert(fn_spec.output_type());
} }
let output_name = fn_spec.output_type(); for (_, body) in &channel_types {
if reachable.insert(output_name) { collect_refs(body, &mut |r| {
frontier.push(output_name); reachable.insert(r);
}
}
while let Some(name) = frontier.pop() {
// Inlines don't carry refs we care about (Primitive/Enum); skip.
if inlines.contains_key(name) {
continue;
}
let body = match candidates.get(name) {
Some(b) => b.clone(),
None => continue,
};
collect_refs(&body, &mut |r| {
if reachable.insert(r) {
frontier.push(r);
}
}); });
} }
let types: BTreeMap<&'static str, NamedType> = candidates // Grow the set until a pass adds nothing: a candidate contributes the
// names it refs once it is itself reachable.
loop {
let mut grew = false;
for (name, body) in &candidates {
if reachable.contains(name) {
collect_refs(body, &mut |r| {
grew |= reachable.insert(r);
});
}
}
if !grew {
break;
}
}
let mut types: BTreeMap<String, NamedType> = candidates
.into_iter() .into_iter()
.filter(|(name, _)| reachable.contains(name)) .filter(|(name, _)| reachable.contains(name))
.map(|(name, body)| (name.to_string(), body))
.collect(); .collect();
types.extend(channel_types);
// Functions: alphabetical by wire name (canonical IR ordering, // Functions: alphabetical by wire name. Skip `private`.
// matches the Python emitter's `sorted(functions)`). Skip `private`.
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
.iter() .iter()
.copied() .copied()
@@ -416,8 +491,8 @@ impl IrSnapshot {
.collect(); .collect();
functions.sort_by_key(|f| f.name()); functions.sort_by_key(|f| f.name());
// Contexts: alphabetical by name (canonical IR ordering), each with // Contexts: alphabetical by name, each with its members sorted
// its members sorted alphabetically too. // alphabetically too.
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect(); let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
context_names.sort(); context_names.sort();
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new(); let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
@@ -437,6 +512,7 @@ impl IrSnapshot {
types, types,
functions, functions,
contexts, contexts,
channels,
inlines, inlines,
} }
} }
@@ -457,7 +533,7 @@ fn collect_refs<F: FnMut(&'static str)>(body: &NamedType, visit: &mut F) {
fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) { fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
match shape { match shape {
TypeShape::Ref(name) => visit(name), TypeShape::Ref { name, .. } => visit(name),
TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit), TypeShape::List(inner) | TypeShape::Optional(inner) => walk_shape_refs(inner, visit),
TypeShape::Union(branches) => { TypeShape::Union(branches) => {
for b in branches { for b in branches {
@@ -468,41 +544,41 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
} }
} }
/// Build the Mizan IR for every registered type/function/context. Returns KDL. /// Build the Mizan IR for every registered type, function, context and
/// channel. Returns KDL.
pub fn build_ir() -> String { pub fn build_ir() -> String {
crate::graph_check::verify_invariants(); crate::graph_check::verify_invariants();
let snap = IrSnapshot::collect(); let snap = IrSnapshot::collect();
let mut em = Emitter::new(&snap.inlines); let builder = NodeBuilder {
inlines: &snap.inlines,
};
// Type definitions let sections: Vec<Vec<KdlNode>> = [
let types_emitted = !snap.types.is_empty(); snap.types
for (name, body) in &snap.types { .iter()
em.emit_named_type(0, name, body); .map(|(name, body)| builder.named_type(0, name, body))
} .collect::<Vec<_>>(),
if types_emitted { snap.functions
em.blank(); .iter()
} .map(|fn_spec| builder.function(0, *fn_spec))
.collect(),
snap.contexts
.iter()
.map(|(ctx_name, members)| builder.context(0, ctx_name, members))
.collect(),
snap.channels
.iter()
.map(|channel| builder.channel(0, channel))
.collect(),
]
.into_iter()
.filter(|section: &Vec<KdlNode>| !section.is_empty())
.collect();
// Functions let mut env = Environment::new();
let fns_emitted = !snap.functions.is_empty(); env.add_filter("kdl", render_kdl_value);
for fn_spec in &snap.functions { env.template_from_named_str("ir.kdl", IR_TEMPLATE)
em.emit_function(0, *fn_spec); .expect("compile templates/ir.kdl.jinja")
} .render(context! { sections })
if fns_emitted { .expect("render templates/ir.kdl.jinja")
em.blank();
}
// Contexts
let ctxs_emitted = !snap.contexts.is_empty();
for (ctx_name, members) in &snap.contexts {
em.emit_context(0, ctx_name, members);
}
if ctxs_emitted {
em.blank();
}
// Future: channels — once channel registry lands on the Rust side.
em.into_string()
} }

View File

@@ -1,15 +1,14 @@
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`. //! Mizan server-side IR substrate.
//! //!
//! Three load-bearing concerns: //! Three load-bearing concerns:
//! //!
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent //! 1. **IR data model + KDL emitter.** `build_ir()` renders the registries as
//! KDL to the Python emitter. Both backends emit the same contract. //! one Mizan IR document.
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate //! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the //! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`, `CHANNELS`)
//! consumer crate's expansion sites. //! at the consumer crate's expansion sites.
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` / //! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP //! `function_named` / `context_members`, which the adapters call per request.
//! adapter calls these per request.
//! //!
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at //! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`. //! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
@@ -22,12 +21,13 @@ pub mod runtime;
pub mod traits; pub mod traits;
pub use ir::{ pub use ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
TypeShape,
}; };
pub use kdl::{build_ir, snake_to_camel}; pub use kdl::{build_ir, snake_to_camel, wire_to_pascal};
pub use registry::{ pub use registry::{
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS, context_members, function_named, ChannelEntry, ChannelSlot, ContextEntry, TypeEntry, CHANNELS,
FUNCTIONS, TYPES, CONTEXTS, FUNCTIONS, TYPES,
}; };
pub use runtime::{ pub use runtime::{
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError, compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
@@ -35,21 +35,20 @@ pub use runtime::{
}; };
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
// Re-export proc macros so consumers depend on one crate. pub use mizan_macros::{channel, client, context, Mizan};
pub use mizan_macros::{client, context, Mizan};
pub mod prelude { pub mod prelude {
pub use crate::ir::{ pub use crate::ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, AffectTarget, ChannelSlotKind, DefaultValue, NamedType, Primitive, StructField, Transport,
TypeShape,
}; };
pub use crate::registry::{ContextEntry, TypeEntry}; pub use crate::registry::{ChannelEntry, ChannelSlot, ContextEntry, TypeEntry};
pub use crate::runtime::{MizanError, RequestHandle}; pub use crate::runtime::{MizanError, RequestHandle};
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
pub use mizan_macros::Mizan; pub use mizan_macros::Mizan;
} }
/// Internal re-exports used by `mizan-macros`-generated code. Not part of /// The crates `mizan-macros` expansions name by absolute path.
/// the public API — consumers must not depend on names under `__priv`.
#[doc(hidden)] #[doc(hidden)]
pub mod __priv { pub mod __priv {
pub use linkme; pub use linkme;

View File

@@ -2,7 +2,7 @@
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]` //! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
//! statics that land here at link time. //! statics that land here at link time.
use crate::ir::NamedType; use crate::ir::{ChannelSlotKind, NamedType};
use crate::traits::FunctionSpec; use crate::traits::FunctionSpec;
use linkme::distributed_slice; use linkme::distributed_slice;
@@ -17,6 +17,21 @@ pub struct ContextEntry {
pub name: &'static str, pub name: &'static str,
} }
/// One declared payload slot of a channel. `shape_fn` yields the shape the
/// slot's type emits under its derived name.
pub struct ChannelSlot {
pub kind: ChannelSlotKind,
pub shape_fn: fn() -> NamedType,
}
/// One channel registration. Emitted by `#[mizan::channel]`. `slots` carries
/// only the slots the channel declares, ordered params, client-message,
/// server-message.
pub struct ChannelEntry {
pub name: &'static str,
pub slots: &'static [ChannelSlot],
}
#[distributed_slice] #[distributed_slice]
pub static TYPES: [TypeEntry] = [..]; pub static TYPES: [TypeEntry] = [..];
@@ -26,18 +41,21 @@ pub static CONTEXTS: [ContextEntry] = [..];
#[distributed_slice] #[distributed_slice]
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..]; pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
/// Find a registered function by wire name. Used by the HTTP adapter. #[distributed_slice]
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> { pub static CHANNELS: [ChannelEntry] = [..];
FUNCTIONS.iter().copied().find(|f| f.name() == name)
/// The functions registered under `name`. Order matches `FUNCTIONS` iteration
/// order — i.e., registration order.
pub fn function_named(name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.filter(|f| f.name() == name)
.collect()
} }
/// Find a registered context by name. Used by graph_check. /// The functions that declare `ctx_name` as their `context` membership. Order
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> { /// matches `FUNCTIONS` iteration order — i.e., registration order.
CONTEXTS.iter().find(|c| c.name == name)
}
/// All functions that declare a given context as their `context` membership.
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> { pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS FUNCTIONS
.iter() .iter()

View File

@@ -1,40 +1,41 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge //! Runtime helpers — error envelope, request handle, and the per-response
//! resolution. Ports `compute_invalidation` / `compute_merges` / //! invalidation / merge resolution the adapters call after a dispatch.
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
use crate::registry::context_members; use crate::registry::context_members;
use crate::traits::FunctionSpec; use crate::traits::FunctionSpec;
use serde_json::Value; use serde_json::Value;
use std::any::Any; use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter /// A borrow of the request object a hosting framework owns.
/// stuffs its native `Request` here; user code casts back via the adapter's ///
/// helper types. /// `FUNCTIONS` is a non-generic `distributed_slice`, so `FunctionSpec` has to
/// be object-safe and no type parameter can reach this handle. The reference
/// therefore rides erased, and the crate that names the framework's own type
/// is the one that casts back to it.
#[derive(Clone)] #[derive(Clone)]
pub struct RequestHandle<'a> { pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync), inner: &'a (dyn Any + Send + Sync),
} }
impl<'a> RequestHandle<'a> { impl<'a> RequestHandle<'a> {
/// Wrap a typed reference. The most common path — handlers downcast back /// Wrap a typed reference.
/// to `T` via `downcast::<T>()`.
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self { pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req } Self { inner: req }
} }
/// Wrap an already-erased `dyn Any` reference. Used by HTTP adapters /// Wrap a reference the caller has already erased.
/// that thread an `Arc<dyn Any + Send + Sync>` app state in.
pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self { pub fn from_dyn(req: &'a (dyn Any + Send + Sync)) -> Self {
Self { inner: req } Self { inner: req }
} }
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> { /// The reference the adapter installed.
self.inner.downcast_ref::<T>() pub fn installed(&self) -> &'a (dyn Any + Send + Sync) {
self.inner
} }
} }
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum. /// Mizan's standard error envelope — the closed set of failures an adapter
/// renders onto the wire.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum MizanError { pub enum MizanError {
NotFound(String), NotFound(String),
@@ -186,59 +187,28 @@ pub fn compute_invalidation(
.collect() .collect()
} }
/// Build the `merge` list from a function's `merge` metadata. Each entry /// Build the `merge` list from the function's already-resolved merge entries.
/// names the slot inside the context bundle the return value lands in. /// Each names the slot inside the context bundle the return value lands in.
pub fn compute_merges( pub fn compute_merges(
fn_spec: &dyn FunctionSpec, fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>, args: &serde_json::Map<String, Value>,
result: &Value, result: &Value,
) -> Vec<MergeEntry> { ) -> Vec<MergeEntry> {
let targets = fn_spec.merge(); crate::graph_check::merges_for(fn_spec.name())
if targets.is_empty() { .map(|resolved| {
return Vec::new(); let scoped = scoped_params(resolved.context, args);
} MergeEntry {
let mutation_output = fn_spec.output_type(); context: resolved.context.into(),
let mut out = Vec::new(); slot: resolved.slot.into(),
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(), value: result.clone(),
params: if scoped.is_empty() { params: if scoped.is_empty() {
None None
} else { } else {
Some(scoped) Some(scoped)
}, },
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
} }
})
.collect()
} }
/// Match input args against the context's declared Input field names. /// Match input args against the context's declared Input field names.
@@ -258,3 +228,36 @@ fn scoped_params(
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::RequestHandle;
use std::any::Any;
fn installed_addr(handle: &RequestHandle<'_>) -> *const () {
handle.installed() as *const (dyn Any + Send + Sync) as *const ()
}
#[test]
fn a_handle_installs_the_very_reference_it_was_built_over() {
let state = String::from("app-state");
let source = &state as *const String as *const ();
assert_eq!(installed_addr(&RequestHandle::new(&state)), source);
}
#[test]
fn an_erased_handle_installs_what_a_typed_one_does() {
let state = String::from("app-state");
assert_eq!(
installed_addr(&RequestHandle::from_dyn(&state)),
installed_addr(&RequestHandle::new(&state))
);
}
#[test]
fn the_installed_reference_keeps_the_type_it_was_built_over() {
let state = String::from("app-state");
let handle = RequestHandle::new(&state);
assert!(handle.installed().is::<String>());
assert!(!handle.installed().is::<i64>());
}
}

View File

@@ -1,4 +1,4 @@
//! Surface traits the proc macros implement. //! The traits a registered Mizan type, context and function implement.
use crate::ir::{AffectTarget, NamedType, Transport}; use crate::ir::{AffectTarget, NamedType, Transport};
use crate::runtime::{MizanError, RequestHandle}; use crate::runtime::{MizanError, RequestHandle};
@@ -6,11 +6,10 @@ use serde_json::Value;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`. /// A type that participates in the Mizan IR.
/// ///
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static` /// `TYPE_NAME` is a `const` rather than a function so it can be named from a
/// initializers — TypeEntry's `name` field reads it directly without an /// `static` initializer.
/// init-time function call.
pub trait MizanType { pub trait MizanType {
const TYPE_NAME: &'static str; const TYPE_NAME: &'static str;
fn shape() -> NamedType; fn shape() -> NamedType;
@@ -20,21 +19,22 @@ pub trait MizanType {
} }
} }
/// A marker type for a Mizan context. Generated by `#[mizan::context]`. /// A marker type carrying one context's wire name.
pub trait ContextMarker { pub trait ContextMarker {
const NAME: &'static str; const NAME: &'static str;
} }
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns. /// One Mizan-registered function: plain data throughout except `dispatch`.
///
/// Everything here is plain data except `dispatch`, which is the type-erased
/// runtime entry point used by the HTTP adapter.
pub trait FunctionSpec: Send + Sync { pub trait FunctionSpec: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
fn camel_name(&self) -> &'static str; fn camel_name(&self) -> &'static str;
fn has_input(&self) -> bool; fn has_input(&self) -> bool;
fn input_type(&self) -> Option<&'static str>; fn input_type(&self) -> Option<&'static str>;
fn output_type(&self) -> &'static str; fn output_type(&self) -> &'static str;
/// The shape registered under `output_type()`.
fn output_shape(&self) -> NamedType;
fn output_nullable(&self) -> bool { fn output_nullable(&self) -> bool {
false false
} }
@@ -63,16 +63,14 @@ pub trait FunctionSpec: Send + Sync {
None None
} }
/// Field-shape description of this function's Input parameters, used by /// This function's Input parameters. Empty when `has_input()` is false.
/// the context builder to compute shared-param elevation. Empty when
/// `has_input()` is false.
fn input_params(&self) -> &'static [InputParam] { fn input_params(&self) -> &'static [InputParam] {
&[] &[]
} }
/// Type-erased dispatch. The HTTP adapter calls this with deserialized /// Deserializes `args` into this function's typed input, awaits the body,
/// JSON arguments; the macro-generated impl deserializes into the /// and serializes the result — the whole call with its types erased behind
/// function's typed input, awaits the body, and serializes the result. /// JSON.
fn dispatch<'a>( fn dispatch<'a>(
&'a self, &'a self,
req: RequestHandle<'a>, req: RequestHandle<'a>,
@@ -80,10 +78,7 @@ pub trait FunctionSpec: Send + Sync {
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>; ) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
} }
/// One parameter of a function's synthesized Input. The macro emits a static /// One parameter of a function's synthesized Input.
/// slice of these so the context builder can find shared params across
/// context members and produce the `context { param ... shared-by ... }`
/// section of the IR.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct InputParam { pub struct InputParam {
pub name: &'static str, pub name: &'static str,

View File

@@ -0,0 +1,6 @@
{% macro node(n) %}{{ n.indent }}{{ n.name }}{% for a in n.args %} {{ a|kdl }}{% endfor %}{% for p in n.props %} {{ p.name }}={{ p.value|kdl }}{% endfor %}{% if n.block %} {
{% for c in n.children %}{{ node(c) }}{% endfor %}{{ n.indent }}}
{% else %}
{% endif %}{% endmacro %}
{%- for section in sections %}{% for n in section %}{{ node(n) }}{% endfor %}{% if not loop.last %}
{% endif %}{% endfor %}

View File

@@ -1,11 +1,8 @@
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros) //! `build_ir()` renders the proc-macro-populated registries; the emitted KDL
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical //! is parsed by the `kdl` crate and then compared byte for byte with
//! Python-emitted reference). //! `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl`.
//!
//! This is the Phase-2 verifier — the AFI fixture is authored against the
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
//! #[mizan::client]`), not hand-built static specs.
use kdl::{KdlDocument, KdlNode};
use mizan_core as mizan; use mizan_core as mizan;
use mizan_core::prelude::*; use mizan_core::prelude::*;
use mizan_core::RequestHandle; use mizan_core::RequestHandle;
@@ -46,7 +43,17 @@ pub struct StatusOutput {
#[mizan::context("user")] #[mizan::context("user")]
pub struct UserCtx; pub struct UserCtx;
// ─── Fixture functions (mirroring tests/afi/fixture.py) ──────────────────── // ─── Fixture handlers ───────────────────────────────────────────────────────
/// `(order id, owning user id, total)` — the store the order handlers read.
const ORDERS: &[(i64, i64, i64)] = &[(10, 1, 4200), (11, 1, 1750), (12, 2, 990)];
fn profile_of(user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client] #[mizan::client]
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput { pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
@@ -65,29 +72,39 @@ pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
#[mizan::client(context = UserCtx)] #[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput { profile_of(user_id)
user_id,
name: "placeholder".into(),
}
} }
#[mizan::client(context = UserCtx)] #[mizan::client(context = UserCtx)]
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> { pub async fn user_orders(_req: &RequestHandle<'_>, user_id: i64) -> Vec<OrderOutput> {
vec![] ORDERS
.iter()
.filter(|(_, owner, _)| *owner == user_id)
.map(|(id, owner, total)| OrderOutput {
id: *id,
user_id: *owner,
total: *total,
})
.collect()
} }
#[mizan::client(affects = UserCtx)] #[mizan::client(affects = UserCtx)]
pub async fn update_profile( pub async fn update_profile(
_req: &RequestHandle<'_>, _req: &RequestHandle<'_>,
_user_id: i64, user_id: i64,
_name: String, name: String,
) -> StatusOutput { ) -> StatusOutput {
StatusOutput { ok: true } StatusOutput {
ok: user_id > 0 && !name.trim().is_empty(),
}
} }
#[mizan::client] #[mizan::client]
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> { pub async fn find_user(_req: &RequestHandle<'_>, user_id: i64) -> Option<ProfileOutput> {
None ORDERS
.iter()
.any(|(_, owner, _)| *owner == user_id)
.then(|| profile_of(user_id))
} }
#[mizan::client(merge = UserCtx)] #[mizan::client(merge = UserCtx)]
@@ -99,20 +116,96 @@ pub async fn rename_user(
ProfileOutput { user_id, name } ProfileOutput { user_id, name }
} }
// ─── The byte-equivalence test ────────────────────────────────────────────── // ─── Reading the parsed document ────────────────────────────────────────────
fn canonical_kdl_path() -> PathBuf { fn canonical_kdl_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")) PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl") .join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
} }
/// The node's first string argument, or the empty string when it has none.
fn label(node: &KdlNode) -> String {
for entry in node.entries() {
if let Some(s) = entry.value().as_string() {
return s.to_string();
}
}
String::new()
}
/// `(node name, first string argument)` for every node at one level.
fn index(nodes: &[KdlNode]) -> Vec<(String, String)> {
nodes
.iter()
.map(|node| (node.name().value().to_string(), label(node)))
.collect()
}
/// The child nodes of the first `kind "name"` node in `doc`, or an empty slice
/// when the document has no such node or it carries no child block.
fn children_of<'a>(doc: &'a KdlDocument, kind: &str, name: &str) -> &'a [KdlNode] {
for node in doc.nodes() {
if node.name().value() == kind && label(node) == name {
return match node.children() {
Some(block) => block.nodes(),
None => &[],
};
}
}
&[]
}
#[test] #[test]
fn build_ir_matches_canonical_afi_kdl() { fn build_ir_matches_canonical_afi_kdl() {
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL"); let emitted = mizan_core::build_ir();
let actual = mizan_core::build_ir();
if actual != expected { // Parsing before comparing means a malformed emission fails here rather
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { // than as a confusing textual diff.
let parsed: KdlDocument = emitted
.parse()
.expect("build_ir() output is a well-formed KDL document");
let top = index(parsed.nodes());
assert!(
top.contains(&("function".to_string(), "user_orders".to_string())),
"parsed document is missing the user_orders function node: {top:?}",
);
assert!(
top.contains(&("context".to_string(), "user".to_string())),
"parsed document is missing the user context node: {top:?}",
);
assert_eq!(
index(children_of(&parsed, "function", "user_orders")),
vec![
("camel".to_string(), "userOrders".to_string()),
("has-input".to_string(), String::new()),
("input".to_string(), "userOrdersInput".to_string()),
("output".to_string(), "userOrdersOutput".to_string()),
("transport".to_string(), "http".to_string()),
("context".to_string(), "user".to_string()),
],
);
assert_eq!(
index(children_of(&parsed, "context", "user")),
vec![
("function".to_string(), "user_orders".to_string()),
("function".to_string(), "user_profile".to_string()),
("param".to_string(), "user_id".to_string()),
],
);
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
let canonical: KdlDocument = expected
.parse()
.expect("the canonical fixture is a well-formed KDL document");
assert_eq!(
index(parsed.nodes()),
index(canonical.nodes()),
"emitted and canonical documents declare different top-level nodes",
);
if emitted != expected {
for (lineno, (a, b)) in emitted.lines().zip(expected.lines()).enumerate() {
if a != b { if a != b {
panic!( panic!(
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}", "KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
@@ -122,7 +215,7 @@ fn build_ir_matches_canonical_afi_kdl() {
} }
panic!( panic!(
"KDL diverges in length: actual_len={} expected_len={}", "KDL diverges in length: actual_len={} expected_len={}",
actual.len(), emitted.len(),
expected.len(), expected.len(),
); );
} }

View File

@@ -0,0 +1,68 @@
//! `verify_invariants()` over a graph where one `merge` declaration matches
//! two members of the context it names and another matches none.
use mizan_core as mizan;
use mizan_core::graph_check::verify_invariants;
use mizan_core::prelude::*;
use mizan_core::RequestHandle;
use serde::{Deserialize, Serialize};
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Profile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Status {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("user-{user_id}"),
}
}
/// Same output shape as `user_profile`.
#[mizan::client(context = UserCtx)]
pub async fn user_card(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("card-{user_id}"),
}
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Profile {
Profile { user_id, name }
}
/// No member of `user` returns this shape.
#[mizan::client(merge = UserCtx)]
pub async fn mark_seen(_req: &RequestHandle<'_>, user_id: i64) -> Status {
Status { ok: user_id > 0 }
}
#[test]
#[should_panic(expected = "Merge resolution needs exactly one match")]
fn a_merge_matching_several_members_is_ambiguous() {
verify_invariants();
}
#[test]
#[should_panic(expected = "user_card")]
fn an_ambiguous_merge_names_every_candidate_member() {
verify_invariants();
}
#[test]
#[should_panic(expected = "no member of that context has output type")]
fn a_merge_matching_no_member_has_no_slot() {
verify_invariants();
}

View File

@@ -0,0 +1,113 @@
//! `compute_merges` over a graph registered through `#[derive(Mizan)]`,
//! `#[mizan::context]` and `#[mizan::client]`.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{compute_merges, RequestHandle, FUNCTIONS};
use serde::{Deserialize, Serialize};
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct ProfileOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct StatusOutput {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(
_req: &RequestHandle<'_>,
user_id: i64,
name: String,
) -> ProfileOutput {
ProfileOutput { user_id, name }
}
#[mizan::client(affects = UserCtx)]
pub async fn touch_user(_req: &RequestHandle<'_>, user_id: i64) -> StatusOutput {
StatusOutput { ok: user_id > 0 }
}
/// The handlers above register into `FUNCTIONS` inside this test binary, so a
/// name they declare always lands.
fn spec(name: &str) -> &'static dyn FunctionSpec {
for fn_spec in FUNCTIONS.iter().copied() {
if fn_spec.name() == name {
return fn_spec;
}
}
panic!("no registered function named `{name}`");
}
/// `user_id` is a declared param of the `user` context; `name` is not.
fn args() -> serde_json::Map<String, serde_json::Value> {
let mut args = serde_json::Map::new();
args.insert("user_id".to_string(), serde_json::Value::from(7));
args.insert("name".to_string(), serde_json::Value::from("Renamed"));
args
}
fn renamed() -> serde_json::Value {
serde_json::json!({ "user_id": 7, "name": "Renamed" })
}
#[test]
fn a_merge_declaration_resolves_to_the_context_member_sharing_its_output() {
let result = renamed();
let merges = compute_merges(spec("rename_user"), &args(), &result);
let [entry] = merges.as_slice() else {
panic!(
"rename_user declares one merge; got {} entries",
merges.len()
);
};
assert_eq!(entry.context, "user");
assert_eq!(entry.slot, "user_profile");
assert_eq!(entry.value, result);
}
#[test]
fn a_merge_entry_is_scoped_by_the_contexts_declared_params_alone() {
let result = renamed();
let merges = compute_merges(spec("rename_user"), &args(), &result);
let [entry] = merges.as_slice() else {
panic!(
"rename_user declares one merge; got {} entries",
merges.len()
);
};
let mut expected = serde_json::Map::new();
expected.insert("user_id".to_string(), serde_json::Value::from(7));
assert_eq!(entry.params, Some(expected));
}
#[test]
fn a_function_declaring_only_affects_produces_no_merge_entries() {
let result = serde_json::json!({ "ok": true });
assert!(compute_merges(spec("touch_user"), &args(), &result).is_empty());
}
#[test]
fn merge_resolution_answers_identically_across_calls() {
let result = renamed();
let first = compute_merges(spec("rename_user"), &args(), &result);
let second = compute_merges(spec("rename_user"), &args(), &result);
let slots: Vec<&str> = first.iter().map(|e| e.slot.as_str()).collect();
let again: Vec<&str> = second.iter().map(|e| e.slot.as_str()).collect();
assert_eq!(slots, again);
assert_eq!(slots, vec!["user_profile"]);
}

View File

@@ -1,10 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """Launcher: runs the Django ASGI server locally and opens it in a native window."""
mizan Desktop — PyWebView + Django local RPC.
Starts a local Django ASGI server and opens a native desktop window.
All communication between the UI and backend uses mizan server functions.
"""
import os import os
import sys import sys
@@ -13,19 +8,18 @@ import time
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
# Work around Qt WebEngine GPU crashes on some systems # Qt WebEngine crashes on some GPU/driver combinations unless it renders on CPU.
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
def start_server(host: str, port: int): def start_server(host: str, port: int):
"""Start the Django ASGI server in a background thread."""
import django import django
django.setup() django.setup()
# Run migrations on first launch
from django.core.management import call_command from django.core.management import call_command
# --run-syncdb builds the tables on a first launch with no migration files.
call_command("migrate", "--run-syncdb", verbosity=0) call_command("migrate", "--run-syncdb", verbosity=0)
import uvicorn import uvicorn
@@ -38,51 +32,49 @@ def start_server(host: str, port: int):
) )
def wait_for_server(url: str, timeout: float = 10.0): def wait_for_server(url: str, timeout: float = 10.0) -> None:
"""Poll until the server responds.""" """Poll until the server answers, or raise carrying the last refusal."""
from urllib.request import urlopen from urllib.request import urlopen
from urllib.error import URLError
deadline = time.time() + timeout deadline = time.time() + timeout
last_error: OSError | None = None
while time.time() < deadline: while time.time() < deadline:
try: try:
urlopen(url, timeout=1) urlopen(url, timeout=1)
return True return
except (URLError, OSError): except OSError as e:
last_error = e
time.sleep(0.1) time.sleep(0.1)
return False
raise TimeoutError(
f"Django server did not answer {url} within {timeout}s"
) from last_error
def main(): def main():
host = "127.0.0.1" host = "127.0.0.1"
port = 8765 port = 8765
# Start Django in a daemon thread
server = threading.Thread(target=start_server, args=(host, port), daemon=True) server = threading.Thread(target=start_server, args=(host, port), daemon=True)
server.start() server.start()
base_url = f"http://{host}:{port}" base_url = f"http://{host}:{port}"
wait_for_server(f"{base_url}/api/mizan/session/")
if not wait_for_server(f"{base_url}/api/mizan/session/"):
print("ERROR: Django server failed to start", file=sys.stderr)
sys.exit(1)
print(f"Backend running at {base_url}") print(f"Backend running at {base_url}")
# Check if --headless flag is passed (for testing)
if "--headless" in sys.argv: if "--headless" in sys.argv:
print("Headless mode — server running. Press Ctrl+C to stop.") print("Headless mode — server running. Press Ctrl+C to stop.")
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
pass print("Interrupted — stopping the server.", file=sys.stderr)
return return
# Open native window
import webview import webview
window = webview.create_window( webview.create_window(
title="mizan Desktop", title="mizan Desktop",
url=base_url, url=base_url,
width=1024, width=1024,

View File

@@ -1,14 +1,4 @@
""" import logging
Desktop RPC server functions.
Tests mizan's appropriateness for desktop apps:
- Local file system access
- SQLite CRUD
- System introspection
- Real-time channels (file watcher, app status)
- No auth required (single-user desktop)
"""
import os import os
import platform import platform
import shutil import shutil
@@ -21,11 +11,14 @@ from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from mizan.client import client from mizan.client import client
from mizan.channels import ReactChannel from mizan.channels import Channel
from mizan.setup.registry import register from mizan.setup import register
from mizan.channels import register as register_channel from mizan.channels import register as register_channel
logger = logging.getLogger(__name__)
# ============================================================================= # =============================================================================
# System Info # System Info
# ============================================================================= # =============================================================================
@@ -119,6 +112,12 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
): ):
try: try:
stat = entry.stat() stat = entry.stat()
except (PermissionError, OSError) as e:
# A broken symlink or an unreadable entry drops out of the
# listing rather than failing the whole directory.
logger.warning("Skipping %s: %s", entry, e)
continue
entries.append( entries.append(
FileEntry( FileEntry(
name=entry.name, name=entry.name,
@@ -128,8 +127,6 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
) )
) )
except (PermissionError, OSError):
continue
except PermissionError: except PermissionError:
raise PermissionError(f"Cannot read directory: {dir_path}") raise PermissionError(f"Cannot read directory: {dir_path}")
@@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput:
stat = file_path.stat() stat = file_path.stat()
# Safety: limit to 1MB text files # Reads are capped at 1MB so a large binary cannot be pulled into memory.
if stat.st_size > 1_048_576: if stat.st_size > 1_048_576:
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)") raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
@@ -190,7 +187,6 @@ class WriteFileOutput(BaseModel):
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput: def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
file_path = Path(path).expanduser().resolve() file_path = Path(path).expanduser().resolve()
# Safety: only allow writing within home directory
home = Path.home() home = Path.home()
if not str(file_path).startswith(str(home)): if not str(file_path).startswith(str(home)):
raise PermissionError(f"Can only write files within home directory: {home}") raise PermissionError(f"Can only write files within home directory: {home}")
@@ -339,10 +335,13 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
try: try:
note = Note.objects.get(pk=id) note = Note.objects.get(pk=id)
except Note.DoesNotExist:
# Deleting an absent note is reported, not raised.
logger.info("delete_note: note %s is already absent", id)
return DeleteNoteOutput(id=id, deleted=False)
note.delete() note.delete()
return DeleteNoteOutput(id=id, deleted=True) return DeleteNoteOutput(id=id, deleted=True)
except Note.DoesNotExist:
return DeleteNoteOutput(id=id, deleted=False)
register(delete_note, "delete_note") register(delete_note, "delete_note")
@@ -353,17 +352,18 @@ register(delete_note, "delete_note")
# ============================================================================= # =============================================================================
class AppStatusChannel(ReactChannel): class AppStatusChannel(Channel):
"""Push app status updates to the UI (uptime, memory, etc.).""" """Push app status updates to the UI (uptime, memory, etc.)."""
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
uptime_seconds: float uptime_seconds: float
memory_mb: float memory_mb: float
note_count: int note_count: int
timestamp: str timestamp: str
def authorize(self, params=None): def authorize(self, params=None):
return True # Desktop app, no auth needed # One local user owns the whole process; there is no identity to check.
return True
def group(self, params=None): def group(self, params=None):
return "app_status" return "app_status"
@@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel):
register_channel(AppStatusChannel, "app_status") register_channel(AppStatusChannel, "app_status")
class NotesChannel(ReactChannel): class NotesChannel(Channel):
"""Push notifications when notes are modified.""" """Push notifications when notes are modified."""
class DjangoMessage(BaseModel): class ServerMessage(BaseModel):
action: str # "created", "updated", "deleted" action: str # "created", "updated", "deleted"
note_id: int note_id: int
title: str title: str
def authorize(self, params=None): def authorize(self, params=None):
# One local user owns the whole process; there is no identity to check.
return True return True
def group(self, params=None): def group(self, params=None):

View File

@@ -4,10 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mizan Desktop</title> <title>mizan Desktop</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -12,9 +12,11 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }

View File

@@ -1,47 +1,46 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan' import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
// ─── System Info ──────────────────────────────────────────────────────────── type SystemFacts = Record<string, unknown>
type NoteList = { notes: Note[] }
type FileListing = { directory: string; entries: FileEntry[]; parent: string | null }
function SystemInfo() { function SystemInfo() {
const { call } = useMizan() const { call } = useMizan()
const [info, setInfo] = useState<Record<string, unknown> | null>(null) const [info, setInfo] = useState<SystemFacts | null>(null)
const [error, setError] = useState<Error | null>(null)
useEffect(() => { useEffect(() => {
call('system_info').then(setInfo).catch(() => {}) call<undefined, SystemFacts>('system_info').then(setInfo).catch(setError)
}, [call]) }, [call])
if (!info) return <div style={styles.card}>Loading system info...</div>
return ( return (
<div style={styles.card}> <div className="panel p-5 mb-4">
<h2 style={styles.h2}>System</h2> <h2 className="panel-heading mb-3">System</h2>
<table style={styles.table}> {error && <div className="load-error">{error.message}</div>}
{!info && !error && <div className="muted">Loading system info...</div>}
{info && (
<table className="info-table w-full">
<tbody> <tbody>
{Object.entries(info).map(([k, v]) => ( {Object.entries(info).map(([k, v]) => (
<tr key={k}> <tr key={k}>
<td style={styles.label}>{k}</td> <td className="info-key py-1 pr-3 whitespace-nowrap">{k}</td>
<td style={styles.value}>{String(v)}</td> <td className="py-1 break-all">{String(v)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
) )
} }
// ─── Connection Status ──────────────────────────────────────────────────────
function StatusBar() { function StatusBar() {
const status = useMizanStatus() const status = useMizanStatus()
return ( const tone = status === 'connected' ? 'status-bar--online' : 'status-bar--offline'
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
{status}
</div>
)
}
// ─── Notes ────────────────────────────────────────────────────────────────── return <div className={`status-bar ${tone}`}>{status}</div>
}
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string } type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
@@ -51,34 +50,41 @@ function Notes() {
const [selected, setSelected] = useState<Note | null>(null) const [selected, setSelected] = useState<Note | null>(null)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [error, setError] = useState<Error | null>(null)
const refresh = useCallback(() => { const refresh = useCallback(() => {
call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {}) call<undefined, NoteList>('list_notes').then(d => setNotes(d.notes)).catch(setError)
}, [call]) }, [call])
useEffect(() => { refresh() }, [refresh]) useEffect(() => { refresh() }, [refresh])
const create = async () => { const clearDraft = () => {
if (!title.trim()) return
await call('create_note', { title, content })
setTitle('')
setContent('')
refresh()
}
const save = async () => {
if (!selected) return
await call('update_note', { id: selected.id, title, content })
setSelected(null) setSelected(null)
setTitle('') setTitle('')
setContent('') setContent('')
refresh()
} }
const remove = async (id: number) => { const create = () => {
await call('delete_note', { id }) if (!title.trim()) return
if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') } call('create_note', { title, content })
.then(() => { clearDraft(); refresh() })
.catch(setError)
}
const save = () => {
if (!selected) return
call('update_note', { id: selected.id, title, content })
.then(() => { clearDraft(); refresh() })
.catch(setError)
}
const remove = (id: number) => {
call('delete_note', { id })
.then(() => {
if (selected?.id === id) clearDraft()
refresh() refresh()
})
.catch(setError)
} }
const select = (n: Note) => { const select = (n: Note) => {
@@ -88,44 +94,50 @@ function Notes() {
} }
return ( return (
<div style={styles.card}> <div className="panel p-5 mb-4">
<h2 style={styles.h2}>Notes ({notes.length})</h2> <h2 className="panel-heading mb-3">Notes ({notes.length})</h2>
<div style={{ display: 'flex', gap: 12 }}> {error && <div className="load-error mb-2">{error.message}</div>}
<div style={{ flex: 1 }}> <div className="flex gap-3">
<div className="flex-1">
{notes.map(n => ( {notes.map(n => (
<div <div
key={n.id} key={n.id}
onClick={() => select(n)} onClick={() => select(n)}
style={{ className={
...styles.noteItem, 'note-item flex items-center justify-between py-2 px-3 mb-0.5 ' +
borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent', (selected?.id === n.id ? 'note-item--selected' : '')
}} }
> >
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span> <span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
<button onClick={e => { e.stopPropagation(); remove(n.id) }} style={styles.deleteBtn}>x</button> <button
onClick={e => { e.stopPropagation(); remove(n.id) }}
className="icon-btn py-0.5 px-1.5"
>
x
</button>
</div> </div>
))} ))}
{notes.length === 0 && <div style={{ color: '#666', padding: 8 }}>No notes yet</div>} {notes.length === 0 && <div className="muted p-2">No notes yet</div>}
</div> </div>
<div style={{ flex: 2 }}> <div className="flex-[2]">
<input <input
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
placeholder="Title" placeholder="Title"
style={styles.input} className="field w-full py-2 px-3 mb-2"
/> />
<textarea <textarea
value={content} value={content}
onChange={e => setContent(e.target.value)} onChange={e => setContent(e.target.value)}
placeholder="Content" placeholder="Content"
rows={6} rows={6}
style={{ ...styles.input, resize: 'vertical' }} className="field w-full py-2 px-3 mb-2 resize-y"
/> />
<button onClick={selected ? save : create} style={styles.btn}> <button onClick={selected ? save : create} className="btn py-2 px-4 mr-2">
{selected ? 'Save' : 'Create'} {selected ? 'Save' : 'Create'}
</button> </button>
{selected && ( {selected && (
<button onClick={() => { setSelected(null); setTitle(''); setContent('') }} style={{ ...styles.btn, background: '#333' }}> <button onClick={clearDraft} className="btn btn--muted py-2 px-4 mr-2">
Cancel Cancel
</button> </button>
)} )}
@@ -135,8 +147,6 @@ function Notes() {
) )
} }
// ─── File Browser ───────────────────────────────────────────────────────────
type FileEntry = { name: string; path: string; is_dir: boolean; size: number } type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
function FileBrowser() { function FileBrowser() {
@@ -144,25 +154,27 @@ function FileBrowser() {
const [dir, setDir] = useState('~') const [dir, setDir] = useState('~')
const [entries, setEntries] = useState<FileEntry[]>([]) const [entries, setEntries] = useState<FileEntry[]>([])
const [parent, setParent] = useState<string | null>(null) const [parent, setParent] = useState<string | null>(null)
const [error, setError] = useState<Error | null>(null)
const browse = useCallback((d: string) => { const browse = useCallback((d: string) => {
call<{ directory: string; entries: FileEntry[]; parent: string | null }>('list_files', { directory: d }) call<{ directory: string }, FileListing>('list_files', { directory: d })
.then(data => { .then(data => {
setDir(data.directory) setDir(data.directory)
setEntries(data.entries.slice(0, 50)) setEntries(data.entries.slice(0, 50))
setParent(data.parent) setParent(data.parent)
}) })
.catch(() => {}) .catch(setError)
}, [call]) }, [call])
useEffect(() => { browse('~') }, [browse]) useEffect(() => { browse('~') }, [browse])
return ( return (
<div style={styles.card}> <div className="panel p-5 mb-4">
<h2 style={styles.h2}>Files</h2> <h2 className="panel-heading mb-3">Files</h2>
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>{dir}</div> {error && <div className="load-error mb-2">{error.message}</div>}
<div className="path-line mb-2">{dir}</div>
{parent && ( {parent && (
<div onClick={() => browse(parent)} style={{ ...styles.fileItem, color: '#6cf', cursor: 'pointer' }}> <div onClick={() => browse(parent)} className="file-item file-item--dir py-1 px-2">
../ (parent) ../ (parent)
</div> </div>
)} )}
@@ -170,24 +182,22 @@ function FileBrowser() {
<div <div
key={e.path} key={e.path}
onClick={() => e.is_dir && browse(e.path)} onClick={() => e.is_dir && browse(e.path)}
style={{ ...styles.fileItem, cursor: e.is_dir ? 'pointer' : 'default', color: e.is_dir ? '#6cf' : '#ccc' }} className={'file-item py-1 px-2 ' + (e.is_dir ? 'file-item--dir' : 'file-item--file')}
> >
{e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name} {e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name}
{!e.is_dir && <span style={{ color: '#666', marginLeft: 8 }}>{(e.size / 1024).toFixed(1)}K</span>} {!e.is_dir && <span className="file-size ml-2">{(e.size / 1024).toFixed(1)}K</span>}
</div> </div>
))} ))}
</div> </div>
) )
} }
// ─── App ────────────────────────────────────────────────────────────────────
export function App() { export function App() {
return ( return (
<MizanProvider baseUrl="/api/mizan" autoConnect={false}> <MizanProvider baseUrl="/api/mizan" autoConnect={false}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}> <div className="max-w-[960px] mx-auto p-6">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div className="flex items-center justify-between mb-6">
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1> <h1 className="app-title">mizan Desktop</h1>
<StatusBar /> <StatusBar />
</div> </div>
<SystemInfo /> <SystemInfo />
@@ -197,19 +207,3 @@ export function App() {
</MizanProvider> </MizanProvider>
) )
} }
// ─── Styles ─────────────────────────────────────────────────────────────────
const styles: Record<string, React.CSSProperties> = {
card: { background: '#1a1a1a', borderRadius: 8, padding: 20, marginBottom: 16 },
h2: { fontSize: 16, marginBottom: 12, color: '#aaa', textTransform: 'uppercase', letterSpacing: 1 },
table: { width: '100%', fontSize: 14 },
label: { padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap' },
value: { padding: '4px 0', wordBreak: 'break-all' },
input: { width: '100%', padding: '8px 12px', marginBottom: 8, background: '#111', border: '1px solid #333', borderRadius: 4, color: '#e0e0e0', fontSize: 14 },
btn: { padding: '8px 16px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', marginRight: 8, fontSize: 14 },
noteItem: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', cursor: 'pointer', borderRadius: 4, marginBottom: 2 },
deleteBtn: { background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 14, padding: '2px 6px' },
fileItem: { padding: '4px 8px', fontSize: 14 },
statusBar: { fontSize: 12, fontFamily: 'monospace' },
}

View File

@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from './App' import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(<App />) createRoot(document.getElementById('root')!).render(<App />)

View File

@@ -0,0 +1,115 @@
@import "tailwindcss";
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
}
.app-title {
font-size: 24px;
color: #fff;
}
.status-bar {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, monospace;
}
.status-bar--online {
color: #4ade80;
}
.status-bar--offline {
color: #f87171;
}
.panel {
background: #1a1a1a;
border-radius: 8px;
}
.panel-heading {
font-size: 16px;
color: #aaa;
text-transform: uppercase;
letter-spacing: 1px;
}
.info-table {
font-size: 14px;
}
.info-key {
color: #888;
}
.muted {
color: #666;
}
.load-error {
color: #f87171;
font-size: 14px;
}
.note-item {
border-radius: 4px;
border-left: 3px solid transparent;
cursor: pointer;
}
.note-item--selected {
border-left-color: #6cf;
}
.field {
background: #111;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 14px;
}
.btn {
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn--muted {
background: #333;
}
.icon-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 14px;
}
.path-line {
color: #888;
font-size: 13px;
}
.file-item {
font-size: 14px;
}
.file-item--dir {
color: #6cf;
cursor: pointer;
}
.file-item--file {
color: #ccc;
}
.file-size {
color: #666;
}

View File

@@ -1,11 +1,12 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src') const reactPkg = path.resolve(__dirname, '../../../frontends/mizan-react/src')
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'mizan/channels': path.join(reactPkg, 'channels/index.ts'), 'mizan/channels': path.join(reactPkg, 'channels/index.ts'),

View File

@@ -10,7 +10,7 @@ dependencies = [
] ]
[tool.uv.sources] [tool.uv.sources]
mizan = { path = "../django", editable = true } mizan = { path = "../../backends/mizan-django", editable = true }
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [

Some files were not shown because too many files have changed in this diff Show More